diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java index b47a741eaec..703e377e852 100644 --- a/apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/OpenTelemetryMapper.java @@ -6,9 +6,11 @@ import com.comet.opik.domain.mapping.OpenTelemetryMappingRuleFactory; import com.comet.opik.domain.mapping.otel.GenAIMappingRules; import com.comet.opik.domain.mapping.otel.GeneralMappingRules; +import com.comet.opik.domain.mapping.otel.OpenInferenceSpanNormalizer; import com.comet.opik.domain.mapping.otel.ProviderResolvers; import com.comet.opik.domain.retention.RetentionUtils; import com.comet.opik.utils.JsonUtils; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.opentelemetry.proto.common.v1.AnyValue; import io.opentelemetry.proto.common.v1.KeyValue; @@ -153,6 +155,7 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List tags = new HashSet<>(); + var openInference = OpenInferenceSpanNormalizer.normalize(attributes).orElse(null); // Claude Code spans carry a lot of session/config attributes that aren't input. For that // integration the default bucket for unmapped attributes is metadata (not input), so only // the explicitly promoted content attributes land in input/output/usage. @@ -160,7 +163,7 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List "thread_id".equals(attribute.getKey())) + .map(KeyValue::getValue) + .findFirst(); + if (explicitThreadId.isPresent()) { + extractToJsonColumn(metadata, "thread_id", explicitThreadId.get()); + } else if (StringUtils.isNotBlank(openInference.sessionId())) { + metadata.put("thread_id", openInference.sessionId()); + } + + usage.putAll(openInference.usage()); + tags.addAll(openInference.tags()); + if (StringUtils.isNotBlank(openInference.model())) { + model = openInference.model(); + } + if (StringUtils.isNotBlank(openInference.provider())) { + provider = openInference.provider(); + } + if (openInference.totalEstimatedCost() != null) { + spanBuilder.totalEstimatedCost(openInference.totalEstimatedCost()); + } + } + // Fall back to the current `gen_ai.provider.name` only when the deprecated `gen_ai.system` // did not report a provider. // Both sides must be non-blank: a non-string or empty `gen_ai.provider.name` yields "" @@ -300,6 +340,9 @@ public static void enrichSpanWithAttributes(SpanBuilder spanBuilder, List RULES = List.of( OpenTelemetryMappingRule.builder() - .rule("llm.invocation_parameters.*").isPrefix(true).source(SOURCE) + // Keep the legacy unmarked-span fallback, but match the real semantic key. + // Marked OpenInference spans are handled atomically by OpenInferenceSpanNormalizer. + .rule("llm.invocation_parameters").source(SOURCE) .outcome(OpenTelemetryMappingRule.Outcome.INPUT).spanType(SpanType.llm).build(), OpenTelemetryMappingRule.builder() .rule("llm.model_name").source(SOURCE).outcome(OpenTelemetryMappingRule.Outcome.MODEL) diff --git a/apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/otel/OpenInferenceSpanNormalizer.java b/apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/otel/OpenInferenceSpanNormalizer.java new file mode 100644 index 00000000000..a8056c1c56b --- /dev/null +++ b/apps/opik-backend/src/main/java/com/comet/opik/domain/mapping/otel/OpenInferenceSpanNormalizer.java @@ -0,0 +1,810 @@ +package com.comet.opik.domain.mapping.otel; + +import com.comet.opik.domain.SpanType; +import com.comet.opik.domain.mapping.OpenTelemetryMappingUtils; +import com.comet.opik.utils.JsonUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.BinaryNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.io.UncheckedIOException; +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Normalizes the flattened OpenInference semantic conventions into Opik's input, output and span fields. + * + *

The normalizer is intentionally gated by the required per-span {@code openinference.span.kind} + * attribute. Instrumentation scope names are batch-level in OTLP and cannot safely identify a span when + * one request contains several integrations.

+ */ +@Slf4j +public final class OpenInferenceSpanNormalizer { + + public static final String SPAN_KIND = "openinference.span.kind"; + + private static final String INPUT_VALUE = "input.value"; + private static final String INPUT_MIME_TYPE = "input.mime_type"; + private static final String OUTPUT_VALUE = "output.value"; + private static final String OUTPUT_MIME_TYPE = "output.mime_type"; + private static final String JSON_MIME_TYPE = "application/json"; + + private static final Pattern MESSAGE_ATTRIBUTE = Pattern.compile( + "^llm\\.(input|output)_messages\\.([^.]+)\\.message\\.(.+)$"); + private static final Pattern MESSAGE_CONTENT_ATTRIBUTE = Pattern.compile( + "^contents\\.([^.]+)\\.(.+)$"); + private static final Pattern MESSAGE_TOOL_CALL_ATTRIBUTE = Pattern.compile( + "^tool_calls\\.([^.]+)\\.tool_call\\.(.+)$"); + private static final Pattern TOOL_ATTRIBUTE = Pattern.compile( + "^llm\\.tools\\.([^.]+)\\.tool\\.(name|description|json_schema)$"); + private static final Pattern PROMPT_ATTRIBUTE = Pattern.compile( + "^llm\\.prompts\\.([^.]+)\\.prompt\\.text$"); + private static final Pattern CHOICE_ATTRIBUTE = Pattern.compile( + "^llm\\.choices\\.([^.]+)\\.completion\\.text$"); + + private static final Map USAGE_KEYS = Map.of( + "llm.token_count.prompt", "prompt_tokens", + "llm.token_count.completion", "completion_tokens", + "llm.token_count.total", "total_tokens", + "llm.token_count.prompt_details.cache_read", "cache_read_input_tokens", + "llm.token_count.prompt_details.cache_write", "cache_creation_input_tokens", + "llm.token_count.prompt_details.audio", "input_audio_tokens", + "llm.token_count.completion_details.reasoning", "reasoning_tokens", + "llm.token_count.completion_details.audio", "output_audio_tokens"); + + private static final Set RESERVED_METADATA_KEYS = Set.of( + "thread_id", "integration", "server.address", SPAN_KIND, INPUT_MIME_TYPE, OUTPUT_MIME_TYPE, + "user.id"); + + private static final Set OPENINFERENCE_PREFIXES = Set.of( + "openinference.", "input.", "output.", "llm.", "session.", "user.", "tag.", "tool.", + "embedding.", "retrieval.", "reranker.", "prompt.", "agent.", "graph."); + + private OpenInferenceSpanNormalizer() { + } + + /** + * Returns a normalization result only when the exact required marker is present on this span. + */ + public static Optional normalize(List attributes) { + if (attributes == null || attributes.stream().noneMatch(attribute -> SPAN_KIND.equals(attribute.getKey()))) { + return Optional.empty(); + } + + var state = new State(); + attributes.forEach(state::accept); + return Optional.of(state.finish()); + } + + /** + * OpenInference values that are applied after the common OTEL rules, so semantic attributes win + * deterministic collisions with raw input/output objects and generic attributes. + */ + public record Result( + Set consumedKeys, + JsonNode rawInput, + boolean hasRawInput, + JsonNode rawOutput, + boolean hasRawOutput, + ObjectNode structuredInput, + ObjectNode structuredOutput, + ObjectNode metadata, + Map usage, + Set tags, + String model, + String provider, + SpanType spanType, + BigDecimal totalEstimatedCost, + String sessionId) { + + public boolean consumes(String key) { + return consumedKeys.contains(key); + } + + public JsonNode composeInput(ObjectNode commonInput) { + return compose(rawInput, hasRawInput, commonInput, structuredInput); + } + + public JsonNode composeOutput(ObjectNode commonOutput) { + return compose(rawOutput, hasRawOutput, commonOutput, structuredOutput); + } + + private static JsonNode compose(JsonNode raw, boolean hasRaw, ObjectNode common, ObjectNode semantic) { + boolean hasCommon = common != null && !common.isEmpty(); + boolean hasSemantic = semantic != null && !semantic.isEmpty(); + + if (hasRaw && !raw.isObject() && !hasCommon && !hasSemantic) { + return raw.deepCopy(); + } + if (!hasRaw && !hasCommon && !hasSemantic) { + return null; + } + + ObjectNode result = JsonUtils.createObjectNode(); + if (hasRaw) { + if (raw.isObject()) { + result.setAll((ObjectNode) raw); + } else { + result.set("value", raw.deepCopy()); + } + } + if (hasCommon) { + result.setAll(common); + } + if (hasSemantic) { + result.setAll(semantic); + } + return result; + } + } + + private static final class State { + + private final Set consumedKeys = new HashSet<>(); + private final ObjectNode input = JsonUtils.createObjectNode(); + private final ObjectNode output = JsonUtils.createObjectNode(); + private final ObjectNode metadata = JsonUtils.createObjectNode(); + private final Map usage = new TreeMap<>(); + private final Set tags = new HashSet<>(); + private final TreeMap inputMessages = new TreeMap<>(); + private final TreeMap outputMessages = new TreeMap<>(); + private final TreeMap tools = new TreeMap<>(); + private final TreeMap prompts = new TreeMap<>(); + private final TreeMap choices = new TreeMap<>(); + + private AnyValue rawInput; + private AnyValue rawOutput; + private String inputMimeType; + private String outputMimeType; + private String responseModel; + private String model; + private String requestModel; + private String provider; + private String system; + private String spanKind; + private String sessionId; + private BigDecimal totalEstimatedCost; + private JsonNode functionCall; + + private void accept(KeyValue attribute) { + String key = attribute.getKey(); + AnyValue value = attribute.getValue(); + + if (acceptExact(key, value) + || acceptMessage(key, value) + || acceptIndexedObject(key, value) + || acceptUsage(key, value)) { + consumedKeys.add(key); + return; + } + + if (isOpenInferenceAttribute(key)) { + metadata.set(key, toJsonNode(value)); + consumedKeys.add(key); + } + } + + private boolean acceptExact(String key, AnyValue value) { + return switch (key) { + case SPAN_KIND -> { + spanKind = stringValueOrMetadata(key, value); + metadata.set(key, toJsonNode(value)); + yield true; + } + case INPUT_VALUE -> { + rawInput = value; + yield true; + } + case OUTPUT_VALUE -> { + rawOutput = value; + yield true; + } + case INPUT_MIME_TYPE -> { + inputMimeType = stringValueOrMetadata(key, value); + metadata.set(key, toJsonNode(value)); + yield true; + } + case OUTPUT_MIME_TYPE -> { + outputMimeType = stringValueOrMetadata(key, value); + metadata.set(key, toJsonNode(value)); + yield true; + } + case "llm.response.model_name" -> { + responseModel = stringValueOrMetadata(key, value); + yield true; + } + case "llm.model_name" -> { + model = stringValueOrMetadata(key, value); + yield true; + } + case "llm.request.model_name" -> { + requestModel = stringValueOrMetadata(key, value); + yield true; + } + case "llm.provider" -> { + provider = stringValueOrMetadata(key, value); + yield true; + } + case "llm.system" -> { + system = stringValueOrMetadata(key, value); + yield true; + } + case "llm.invocation_parameters" -> { + JsonNode parsed = parseJsonStringOrMetadata(key, value); + if (parsed != null) { + input.set("invocation_parameters", parsed); + } + yield true; + } + case "llm.function_call" -> { + functionCall = parseJsonStringOrMetadata(key, value); + yield true; + } + case "llm.finish_reason" -> { + putStringOrMetadata(output, "finish_reason", key, value); + yield true; + } + case "llm.prompt_template.template" -> { + putPromptTemplateField("template", key, value, false); + yield true; + } + case "llm.prompt_template.variables" -> { + putPromptTemplateField("variables", key, value, true); + yield true; + } + case "llm.prompt_template.version" -> { + putPromptTemplateField("version", key, value, false); + yield true; + } + case "llm.cost.total" -> { + totalEstimatedCost = OpenTelemetryMappingUtils.extractCost(value).orElse(null); + if (totalEstimatedCost == null) { + metadata.set(key, toJsonNode(value)); + } + yield true; + } + case "session.id" -> { + sessionId = stringValueOrMetadata(key, value); + yield true; + } + case "user.id" -> { + metadata.set(key, toJsonNode(value)); + yield true; + } + case "tag.tags" -> { + var extractedTags = OpenTelemetryMappingUtils.extractTags(value); + boolean validTagsValue = value.hasStringValue() + || (value.hasArrayValue() + && value.getArrayValue().getValuesList().stream() + .allMatch(AnyValue::hasStringValue)); + if (!validTagsValue) { + metadata.set(key, toJsonNode(value)); + } else { + tags.addAll(extractedTags); + } + yield true; + } + case "metadata" -> { + mergeMetadata(value); + yield true; + } + default -> false; + }; + } + + private boolean acceptMessage(String key, AnyValue value) { + Matcher matcher = MESSAGE_ATTRIBUTE.matcher(key); + if (!matcher.matches()) { + return false; + } + + Integer messageIndex = parseIndex(matcher.group(2)); + if (messageIndex == null) { + metadata.set(key, toJsonNode(value)); + return true; + } + + TreeMap messages = "input".equals(matcher.group(1)) + ? inputMessages + : outputMessages; + MessageBuilder message = messages.computeIfAbsent(messageIndex, ignored -> new MessageBuilder()); + if (!message.accept(matcher.group(3), value)) { + metadata.set(key, toJsonNode(value)); + } + return true; + } + + private boolean acceptIndexedObject(String key, AnyValue value) { + Matcher toolMatcher = TOOL_ATTRIBUTE.matcher(key); + if (toolMatcher.matches()) { + Integer index = parseIndex(toolMatcher.group(1)); + if (index == null) { + metadata.set(key, toJsonNode(value)); + return true; + } + String field = toolMatcher.group(2); + ObjectNode tool = tools.computeIfAbsent(index, ignored -> JsonUtils.createObjectNode()); + if ("json_schema".equals(field)) { + JsonNode parsed = parseJsonStringOrMetadata(key, value); + if (parsed != null) { + tool.set(field, parsed); + } + } else { + putStringOrMetadata(tool, field, key, value); + } + return true; + } + + Matcher promptMatcher = PROMPT_ATTRIBUTE.matcher(key); + if (promptMatcher.matches()) { + Integer index = parseIndex(promptMatcher.group(1)); + if (index == null) { + metadata.set(key, toJsonNode(value)); + return true; + } + ObjectNode prompt = prompts.computeIfAbsent(index, ignored -> JsonUtils.createObjectNode()); + putStringOrMetadata(prompt, "text", key, value); + return true; + } + + Matcher choiceMatcher = CHOICE_ATTRIBUTE.matcher(key); + if (choiceMatcher.matches()) { + Integer index = parseIndex(choiceMatcher.group(1)); + if (index == null) { + metadata.set(key, toJsonNode(value)); + return true; + } + ObjectNode choice = choices.computeIfAbsent(index, ignored -> JsonUtils.createObjectNode()); + putStringOrMetadata(choice, "text", key, value); + return true; + } + return false; + } + + private boolean acceptUsage(String key, AnyValue value) { + String usageKey = USAGE_KEYS.get(key); + if (usageKey == null) { + return false; + } + + Integer tokenCount = nonNegativeInt(value); + if (tokenCount == null) { + metadata.set(key, toJsonNode(value)); + } else { + usage.put(usageKey, tokenCount); + } + return true; + } + + private Result finish() { + if (!inputMessages.isEmpty()) { + ArrayNode messages = buildMessages(inputMessages, false); + if (!messages.isEmpty()) { + input.set("messages", messages); + } + } + if (!outputMessages.isEmpty()) { + ArrayNode messages = buildMessages(outputMessages, true); + if (!messages.isEmpty()) { + output.set("messages", messages); + } + } + if (!tools.isEmpty()) { + ArrayNode normalizedTools = buildArray(tools); + if (!normalizedTools.isEmpty()) { + input.set("tools", normalizedTools); + } + } + if (!prompts.isEmpty()) { + ArrayNode normalizedPrompts = buildArray(prompts); + if (!normalizedPrompts.isEmpty()) { + input.set("prompts", normalizedPrompts); + } + } + if (!choices.isEmpty()) { + ArrayNode normalizedChoices = buildArray(choices); + if (!normalizedChoices.isEmpty()) { + output.set("choices", normalizedChoices); + } + } + if (functionCall != null) { + output.set("function_call", functionCall); + } + + JsonNode parsedInput = rawInput == null ? null : parseRawValue(rawInput, inputMimeType, INPUT_VALUE); + JsonNode parsedOutput = rawOutput == null ? null : parseRawValue(rawOutput, outputMimeType, OUTPUT_VALUE); + + return new Result( + Set.copyOf(consumedKeys), + parsedInput, + rawInput != null, + parsedOutput, + rawOutput != null, + input, + output, + metadata, + Map.copyOf(usage), + Set.copyOf(tags), + StringUtils.firstNonBlank(responseModel, model, requestModel), + StringUtils.firstNonBlank(provider, system), + toSpanType(spanKind), + totalEstimatedCost, + sessionId); + } + + private ArrayNode buildMessages(TreeMap messages, boolean outputSide) { + ArrayNode result = JsonUtils.createArrayNode(); + messages.values().forEach(messageBuilder -> { + ObjectNode message = messageBuilder.build(); + JsonNode legacyFunctionCall = message.remove("function_call"); + if (outputSide && functionCall == null && legacyFunctionCall != null) { + functionCall = legacyFunctionCall; + } else if (legacyFunctionCall != null) { + message.set("function_call", legacyFunctionCall); + } + if (!message.isEmpty()) { + result.add(message); + } + }); + return result; + } + + private void putPromptTemplateField(String field, String originalKey, AnyValue value, boolean parseJson) { + if (parseJson) { + JsonNode parsed = parseJsonStringOrMetadata(originalKey, value); + if (parsed != null) { + promptTemplate().set(field, parsed); + } + return; + } + if (!value.hasStringValue()) { + metadata.set(originalKey, toJsonNode(value)); + return; + } + promptTemplate().put(field, value.getStringValue()); + } + + private ObjectNode promptTemplate() { + JsonNode existing = input.get("prompt_template"); + if (existing instanceof ObjectNode object) { + return object; + } + ObjectNode template = JsonUtils.createObjectNode(); + input.set("prompt_template", template); + return template; + } + + private void mergeMetadata(AnyValue value) { + if (!value.hasStringValue()) { + metadata.set("metadata", toJsonNode(value)); + return; + } + try { + JsonNode parsed = JsonUtils.getJsonNodeFromString(value.getStringValue()); + if (!parsed.isObject()) { + metadata.set("metadata", parsed); + return; + } + parsed.fields().forEachRemaining(entry -> { + if (!RESERVED_METADATA_KEYS.contains(entry.getKey())) { + metadata.set(entry.getKey(), entry.getValue()); + } + }); + } catch (UncheckedIOException exception) { + log.debug("Failed to parse OpenInference metadata as JSON", exception); + metadata.put("metadata", value.getStringValue()); + } + } + + private String stringValueOrMetadata(String key, AnyValue value) { + if (value.hasStringValue()) { + return StringUtils.trimToNull(value.getStringValue()); + } + metadata.set(key, toJsonNode(value)); + return null; + } + + private void putStringOrMetadata(ObjectNode target, String field, String originalKey, AnyValue value) { + if (value.hasStringValue()) { + target.put(field, value.getStringValue()); + } else { + metadata.set(originalKey, toJsonNode(value)); + } + } + + private JsonNode parseJsonStringOrMetadata(String originalKey, AnyValue value) { + if (!value.hasStringValue()) { + metadata.set(originalKey, toJsonNode(value)); + return null; + } + return parseJsonString(value.getStringValue()); + } + } + + private static final class MessageBuilder { + + private final ObjectNode message = JsonUtils.createObjectNode(); + private final TreeMap contents = new TreeMap<>(); + private final TreeMap toolCalls = new TreeMap<>(); + private final ObjectNode functionCall = JsonUtils.createObjectNode(); + + private boolean accept(String path, AnyValue value) { + if (Set.of("role", "content", "name", "tool_call_id").contains(path)) { + if (!value.hasStringValue()) { + return false; + } + message.put(path, value.getStringValue()); + return true; + } + if ("function_call_name".equals(path)) { + if (!value.hasStringValue()) { + return false; + } + functionCall.put("name", value.getStringValue()); + return true; + } + if ("function_call_arguments_json".equals(path)) { + if (!value.hasStringValue()) { + return false; + } + functionCall.set("arguments", parseJsonString(value.getStringValue())); + return true; + } + + Matcher contentMatcher = MESSAGE_CONTENT_ATTRIBUTE.matcher(path); + if (contentMatcher.matches()) { + Integer index = parseIndex(contentMatcher.group(1)); + if (index == null) { + return false; + } + return contents.computeIfAbsent(index, ignored -> new ContentBuilder()) + .accept(contentMatcher.group(2), value); + } + + Matcher toolCallMatcher = MESSAGE_TOOL_CALL_ATTRIBUTE.matcher(path); + if (toolCallMatcher.matches()) { + Integer index = parseIndex(toolCallMatcher.group(1)); + if (index == null) { + return false; + } + return toolCalls.computeIfAbsent(index, ignored -> new ToolCallBuilder()) + .accept(toolCallMatcher.group(2), value); + } + return false; + } + + private ObjectNode build() { + if (!contents.isEmpty()) { + ArrayNode array = JsonUtils.createArrayNode(); + contents.values().stream().map(ContentBuilder::build).filter(node -> !node.isEmpty()) + .forEach(array::add); + if (!array.isEmpty()) { + message.set("contents", array); + } + } + if (!toolCalls.isEmpty()) { + ArrayNode array = JsonUtils.createArrayNode(); + toolCalls.values().stream().map(ToolCallBuilder::build).filter(node -> !node.isEmpty()) + .forEach(array::add); + if (!array.isEmpty()) { + message.set("tool_calls", array); + } + } + if (!functionCall.isEmpty()) { + message.set("function_call", functionCall); + } + return message; + } + } + + private static final class ContentBuilder { + + private final ObjectNode content = JsonUtils.createObjectNode(); + private final ObjectNode image = JsonUtils.createObjectNode(); + private final ObjectNode audio = JsonUtils.createObjectNode(); + private final ToolCallBuilder toolCall = new ToolCallBuilder(); + + private boolean accept(String path, AnyValue value) { + if (Set.of("message_content.type", "message_content.text", "message_content.id", + "message_content.signature", "message_content.data", "message_content.encrypted_content") + .contains(path)) { + if (!value.hasStringValue()) { + return false; + } + content.put(path.substring("message_content.".length()), value.getStringValue()); + return true; + } + if ("message_content.image.image.url".equals(path)) { + if (!value.hasStringValue()) { + return false; + } + image.put("url", value.getStringValue()); + return true; + } + if (path.startsWith("message_content.audio.audio.")) { + String field = path.substring("message_content.audio.audio.".length()); + if (!Set.of("url", "mime_type", "transcript").contains(field) || !value.hasStringValue()) { + return false; + } + audio.put(field, value.getStringValue()); + return true; + } + if (path.startsWith("tool_call.")) { + return toolCall.accept(path.substring("tool_call.".length()), value); + } + return false; + } + + private ObjectNode build() { + if (!image.isEmpty()) { + content.set("image", image); + } + if (!audio.isEmpty()) { + content.set("audio", audio); + } + ObjectNode builtToolCall = toolCall.build(); + if (!builtToolCall.isEmpty()) { + content.set("tool_call", builtToolCall); + } + return content; + } + } + + private static final class ToolCallBuilder { + + private final ObjectNode toolCall = JsonUtils.createObjectNode(); + private final ObjectNode function = JsonUtils.createObjectNode(); + + private boolean accept(String path, AnyValue value) { + if (!value.hasStringValue()) { + return false; + } + return switch (path) { + case "id" -> { + toolCall.put("id", value.getStringValue()); + yield true; + } + case "function.name" -> { + function.put("name", value.getStringValue()); + yield true; + } + case "function.arguments" -> { + // OpenInference defines tool arguments as an opaque JSON string. Preserve it verbatim. + function.put("arguments", value.getStringValue()); + yield true; + } + case "reasoning_signature" -> { + toolCall.put("reasoning_signature", value.getStringValue()); + yield true; + } + default -> false; + }; + } + + private ObjectNode build() { + if (!function.isEmpty()) { + toolCall.set("function", function); + } + return toolCall; + } + } + + private static ArrayNode buildArray(TreeMap values) { + ArrayNode result = JsonUtils.createArrayNode(); + values.values().stream().filter(node -> !node.isEmpty()).forEach(result::add); + return result; + } + + private static Integer parseIndex(String rawIndex) { + try { + int index = Integer.parseInt(rawIndex); + return index >= 0 ? index : null; + } catch (NumberFormatException exception) { + return null; + } + } + + private static Integer nonNegativeInt(AnyValue value) { + long parsed; + if (value.hasIntValue()) { + parsed = value.getIntValue(); + } else if (value.hasStringValue()) { + try { + parsed = Long.parseLong(value.getStringValue()); + } catch (NumberFormatException exception) { + return null; + } + } else { + return null; + } + if (parsed < 0 || parsed > Integer.MAX_VALUE) { + return null; + } + return (int) parsed; + } + + private static JsonNode parseRawValue(AnyValue value, String mimeType, String key) { + if (!value.hasStringValue()) { + return toJsonNode(value); + } + if (!JSON_MIME_TYPE.equals(normalizeMimeType(mimeType))) { + return JsonUtils.valueToTree(value.getStringValue()); + } + try { + return JsonUtils.getJsonNodeFromString(value.getStringValue()); + } catch (UncheckedIOException exception) { + log.debug("Failed to parse OpenInference {} as JSON; preserving the original value", key, exception); + return JsonUtils.valueToTree(value.getStringValue()); + } + } + + private static String normalizeMimeType(String mimeType) { + if (mimeType == null) { + return null; + } + int parameters = mimeType.indexOf(';'); + String type = parameters >= 0 ? mimeType.substring(0, parameters) : mimeType; + return type.trim().toLowerCase(Locale.ROOT); + } + + private static JsonNode parseJsonString(String value) { + return JsonUtils.getJsonNodeFromStringWithFallback(value); + } + + private static SpanType toSpanType(String kind) { + if (kind == null) { + return SpanType.general; + } + return switch (kind.toUpperCase(Locale.ROOT)) { + case "LLM" -> SpanType.llm; + case "TOOL" -> SpanType.tool; + case "GUARDRAIL" -> SpanType.guardrail; + default -> SpanType.general; + }; + } + + private static boolean isOpenInferenceAttribute(String key) { + if ("metadata".equals(key)) { + return true; + } + return OPENINFERENCE_PREFIXES.stream().anyMatch(key::startsWith); + } + + private static JsonNode toJsonNode(AnyValue value) { + return switch (value.getValueCase()) { + case STRING_VALUE -> JsonUtils.valueToTree(value.getStringValue()); + case BOOL_VALUE -> JsonUtils.valueToTree(value.getBoolValue()); + case INT_VALUE -> JsonUtils.valueToTree(value.getIntValue()); + case DOUBLE_VALUE -> JsonUtils.valueToTree(value.getDoubleValue()); + case BYTES_VALUE -> BinaryNode.valueOf(value.getBytesValue().toByteArray()); + case ARRAY_VALUE -> { + ArrayNode array = JsonUtils.createArrayNode(); + value.getArrayValue().getValuesList().stream().map(OpenInferenceSpanNormalizer::toJsonNode) + .forEach(array::add); + yield array; + } + case KVLIST_VALUE -> { + ObjectNode object = JsonUtils.createObjectNode(); + value.getKvlistValue().getValuesList() + .forEach(entry -> object.set(entry.getKey(), toJsonNode(entry.getValue()))); + yield object; + } + case VALUE_NOT_SET -> NullNode.getInstance(); + default -> NullNode.getInstance(); + }; + } +} diff --git a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/OpenTelemetryResourceTest.java b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/OpenTelemetryResourceTest.java index a6f1a599d32..36ad251195c 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/OpenTelemetryResourceTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/priv/OpenTelemetryResourceTest.java @@ -511,6 +511,88 @@ private KeyValue intAttribute(String key, long value) { .setValue(AnyValue.newBuilder().setIntValue(value)).build(); } + @Test + @DisplayName("normalizes OpenInference spans through the OTLP protobuf endpoint") + void testOpenInferenceSpanNormalization() { + String workspaceName = UUID.randomUUID().toString(); + String projectName = "OpenInference Test"; + mockTargetWorkspace(okApikey, workspaceName); + + var otelTraceId = UUID.randomUUID().toString().getBytes(); + long startTimeUnixNano = (System.currentTimeMillis() - 1_000) * 1_000_000L; + long endTimeUnixNano = System.currentTimeMillis() * 1_000_000L; + + var openInferenceSpan = Span.newBuilder() + .setName("openinference llm call") + .setTraceId(ByteString.copyFrom(otelTraceId)) + .setSpanId(ByteString.copyFrom(UUID.randomUUID().toString().getBytes())) + .setStartTimeUnixNano(startTimeUnixNano) + .setEndTimeUnixNano(endTimeUnixNano) + .addAttributes(stringAttribute("llm.output_messages.4.message.content", "Hello from Opik")) + .addAttributes(intAttribute("llm.token_count.completion", 4)) + .addAttributes(stringAttribute("input.value", "{\"request_id\":\"request-7\"}")) + .addAttributes(stringAttribute("llm.input_messages.2.message.content", "Hello")) + .addAttributes(stringAttribute("llm.response.model_name", "gpt-4o-mini")) + .addAttributes(stringAttribute("output.mime_type", "application/json")) + .addAttributes(intAttribute("llm.token_count.prompt", 3)) + .addAttributes(stringAttribute("llm.output_messages.4.message.role", "assistant")) + .addAttributes(stringAttribute("llm.provider", "openai")) + .addAttributes(stringAttribute("input.mime_type", "application/json")) + .addAttributes(intAttribute("llm.token_count.total", 7)) + .addAttributes(stringAttribute("output.value", "{\"response_id\":\"response-7\"}")) + .addAttributes(stringAttribute("llm.input_messages.2.message.role", "user")) + .addAttributes(stringAttribute("openinference.span.kind", "LLM")) + .build(); + + // A neighboring span in the same protobuf batch must keep the legacy generic mapping. + var unmarkedSpan = Span.newBuilder() + .setName("unmarked call") + .setTraceId(ByteString.copyFrom(otelTraceId)) + .setSpanId(ByteString.copyFrom(UUID.randomUUID().toString().getBytes())) + .setStartTimeUnixNano(startTimeUnixNano + 1) + .setEndTimeUnixNano(endTimeUnixNano) + .addAttributes(stringAttribute("llm.output_messages.0.message.content", "leave me unchanged")) + .build(); + + var expectedTraceId = OpenTelemetryMapper.convertOtelIdToUUIDv7( + otelTraceId, Duration.ofNanos(startTimeUnixNano).toMillis()); + + sendProtobufTraces(List.of(openInferenceSpan, unmarkedSpan), projectName, workspaceName, okApikey, true, + null); + + var persistedSpans = spanResourceClient.getByTraceIdAndProject( + expectedTraceId, projectName, workspaceName, okApikey).content(); + assertThat(persistedSpans).hasSize(2); + + var persistedOpenInferenceSpan = persistedSpans.stream() + .filter(span -> "openinference llm call".equals(span.name())) + .findFirst() + .orElseThrow(); + assertThat(persistedOpenInferenceSpan.type()).isEqualTo(SpanType.llm); + assertThat(persistedOpenInferenceSpan.model()).isEqualTo("gpt-4o-mini"); + assertThat(persistedOpenInferenceSpan.provider()).isEqualTo("openai"); + assertThat(persistedOpenInferenceSpan.usage()) + .containsEntry("prompt_tokens", 3) + .containsEntry("completion_tokens", 4) + .containsEntry("total_tokens", 7); + assertThat(persistedOpenInferenceSpan.input().path("request_id").asText()).isEqualTo("request-7"); + assertThat(persistedOpenInferenceSpan.input().path("messages").get(0).path("role").asText()) + .isEqualTo("user"); + assertThat(persistedOpenInferenceSpan.output().path("response_id").asText()).isEqualTo("response-7"); + assertThat(persistedOpenInferenceSpan.output().path("messages").get(0).path("content").asText()) + .isEqualTo("Hello from Opik"); + assertThat(persistedOpenInferenceSpan.metadata().path("openinference.span.kind").asText()) + .isEqualTo("LLM"); + + var persistedUnmarkedSpan = persistedSpans.stream() + .filter(span -> "unmarked call".equals(span.name())) + .findFirst() + .orElseThrow(); + assertThat(persistedUnmarkedSpan.input().path("llm.output_messages.0.message.content").asText()) + .isEqualTo("leave me unchanged"); + assertThat(persistedUnmarkedSpan.output()).isNull(); + } + @Test @DisplayName("test thread_id support in OpenTelemetry") void testThreadIdSupport() { diff --git a/apps/opik-backend/src/test/java/com/comet/opik/domain/OpenTelemetryMapperTest.java b/apps/opik-backend/src/test/java/com/comet/opik/domain/OpenTelemetryMapperTest.java index 8d85c36a04c..f7de0fcaa45 100644 --- a/apps/opik-backend/src/test/java/com/comet/opik/domain/OpenTelemetryMapperTest.java +++ b/apps/opik-backend/src/test/java/com/comet/opik/domain/OpenTelemetryMapperTest.java @@ -20,6 +20,8 @@ import java.math.BigDecimal; import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.stream.Stream; @@ -2310,4 +2312,366 @@ void opikMetadataMergeFiltersReservedKeys(String reservedKey) { assertThat(span.metadata().get("custom").asText()).isEqualTo("ok"); } } + + @Nested + class OpenInferenceNormalization { + + private Span enrich(List attributes) { + var spanBuilder = Span.builder() + .id(UUID.randomUUID()) + .traceId(UUID.randomUUID()) + .projectId(UUID.randomUUID()) + .startTime(Instant.now()); + OpenTelemetryMapper.enrichSpanWithAttributes(spanBuilder, attributes, "mixed-batch-scope", null); + return spanBuilder.build(); + } + + private KeyValue str(String key, String value) { + return KeyValue.newBuilder() + .setKey(key) + .setValue(AnyValue.newBuilder().setStringValue(value)) + .build(); + } + + private KeyValue integer(String key, long value) { + return KeyValue.newBuilder() + .setKey(key) + .setValue(AnyValue.newBuilder().setIntValue(value)) + .build(); + } + + private KeyValue decimal(String key, double value) { + return KeyValue.newBuilder() + .setKey(key) + .setValue(AnyValue.newBuilder().setDoubleValue(value)) + .build(); + } + + private KeyValue bool(String key, boolean value) { + return KeyValue.newBuilder() + .setKey(key) + .setValue(AnyValue.newBuilder().setBoolValue(value)) + .build(); + } + + private KeyValue array(String key, AnyValue... values) { + var value = AnyValue.newBuilder(); + for (AnyValue item : values) { + value.getArrayValueBuilder().addValues(item); + } + return KeyValue.newBuilder().setKey(key).setValue(value).build(); + } + + private KeyValue object(String key, KeyValue... fields) { + var value = AnyValue.newBuilder(); + for (KeyValue field : fields) { + value.getKvlistValueBuilder().addValues(field); + } + return KeyValue.newBuilder().setKey(key).setValue(value).build(); + } + + @Test + void normalizesSparseOutOfOrderChatToolsAndMultimodalContent() { + var attributes = List.of( + str("llm.output_messages.7.message.contents.8.tool_call.function.arguments", + "{\"city\":\"Paris\"}"), + str("llm.input_messages.10.message.content", "{\"temperature\":21}"), + str("llm.output_messages.7.message.contents.4.message_content.image.image.url", + "data:image/png;base64,AAAA"), + str("llm.output_messages.7.message.tool_calls.4.tool_call.function.name", "weather"), + str("llm.input_messages.2.message.role", "human"), + str("llm.output_messages.7.message.contents.1.message_content.signature", "opaque-signature"), + str("llm.output_messages.7.message.contents.6.message_content.audio.audio.transcript", "hello"), + str("llm.output_messages.7.message.contents.8.message_content.type", "tool_use"), + str("llm.output_messages.7.message.contents.6.message_content.audio.audio.mime_type", "audio/wav"), + str("llm.output_messages.7.message.contents.3.message_content.text", "Visible answer"), + str("llm.output_messages.7.message.contents.1.message_content.text", "Visible reasoning"), + str("llm.output_messages.7.message.contents.1.message_content.id", "reasoning-1"), + str("llm.output_messages.7.message.contents.1.message_content.data", "opaque-data"), + str("llm.output_messages.7.message.contents.1.message_content.encrypted_content", + "opaque-encrypted-content"), + str("llm.output_messages.7.message.contents.6.message_content.audio.audio.url", + "https://example.test/audio.wav"), + str("llm.output_messages.7.message.contents.4.message_content.type", "image"), + str("llm.output_messages.7.message.tool_calls.4.tool_call.id", "call-7"), + str("llm.input_messages.10.message.name", "weather"), + str("llm.output_messages.7.message.contents.8.tool_call.id", "call-7"), + str("llm.output_messages.7.message.contents.6.message_content.type", "audio"), + str("llm.output_messages.7.message.contents.8.tool_call.function.name", "weather"), + str("llm.input_messages.2.message.content", "What is the weather?"), + str("llm.output_messages.7.message.tool_calls.4.tool_call.reasoning_signature", "tool-signature"), + str("llm.output_messages.7.message.role", "model"), + str("llm.input_messages.10.message.tool_call_id", "call-7"), + str("llm.output_messages.7.message.contents.3.message_content.type", "text"), + str("llm.output_messages.7.message.tool_calls.4.tool_call.function.arguments", + "{\"city\":\"Paris\"}"), + str("llm.output_messages.7.message.contents.1.message_content.type", "reasoning"), + str("llm.input_messages.10.message.role", "tool"), + str("openinference.span.kind", "LLM")); + + var span = enrich(attributes); + + assertThat(span.input().path("messages").size()).isEqualTo(2); + assertThat(span.input().path("messages").get(0).path("content").asText()) + .isEqualTo("What is the weather?"); + assertThat(span.input().path("messages").get(1).path("tool_call_id").asText()).isEqualTo("call-7"); + + var outputMessage = span.output().path("messages").get(0); + assertThat(outputMessage.path("role").asText()).isEqualTo("model"); + assertThat(outputMessage.path("contents").size()).isEqualTo(5); + assertThat(outputMessage.path("contents").get(0).path("type").asText()).isEqualTo("reasoning"); + assertThat(outputMessage.path("contents").get(0).path("signature").asText()) + .isEqualTo("opaque-signature"); + assertThat(outputMessage.path("contents").get(0).path("id").asText()).isEqualTo("reasoning-1"); + assertThat(outputMessage.path("contents").get(0).path("data").asText()).isEqualTo("opaque-data"); + assertThat(outputMessage.path("contents").get(0).path("encrypted_content").asText()) + .isEqualTo("opaque-encrypted-content"); + assertThat(outputMessage.path("contents").get(2).path("image").path("url").asText()) + .isEqualTo("data:image/png;base64,AAAA"); + assertThat(outputMessage.path("contents").get(3).path("audio").path("mime_type").asText()) + .isEqualTo("audio/wav"); + assertThat(outputMessage.path("contents").get(4).path("tool_call").path("function") + .path("arguments").asText()).isEqualTo("{\"city\":\"Paris\"}"); + assertThat(outputMessage.path("tool_calls").get(0).path("reasoning_signature").asText()) + .isEqualTo("tool-signature"); + assertThat(span.input().fieldNames()).toIterable() + .noneMatch(name -> name.startsWith("llm.")); + assertThat(span.output().fieldNames()).toIterable() + .noneMatch(name -> name.startsWith("llm.")); + } + + @Test + void usesMimeTypeForRawValuesAndMergesSemanticFieldsLast() { + var jsonObject = enrich(List.of( + str("openinference.span.kind", "LLM"), + str("input.mime_type", "application/json; charset=utf-8"), + str("input.value", "{\"messages\":\"raw\",\"question\":\"hello\"}"), + str("llm.input_messages.0.message.role", "user"), + str("llm.input_messages.0.message.content", "semantic"))); + assertThat(jsonObject.input().path("question").asText()).isEqualTo("hello"); + assertThat(jsonObject.input().path("messages").isArray()).isTrue(); + assertThat(jsonObject.input().path("messages").get(0).path("content").asText()).isEqualTo("semantic"); + + var plainText = enrich(List.of( + str("openinference.span.kind", "CHAIN"), + str("input.value", "{\"kept\":\"as text\"}"))); + assertThat(plainText.input().isTextual()).isTrue(); + assertThat(plainText.input().asText()).isEqualTo("{\"kept\":\"as text\"}"); + + var malformedJson = enrich(List.of( + str("openinference.span.kind", "CHAIN"), + str("output.mime_type", "application/json"), + str("output.value", "{invalid"))); + assertThat(malformedJson.output().isTextual()).isTrue(); + assertThat(malformedJson.output().asText()).isEqualTo("{invalid"); + + var scalarWithStructure = enrich(List.of( + str("openinference.span.kind", "LLM"), + str("output.mime_type", "application/json"), + str("output.value", "[1,2,3]"), + str("llm.output_messages.0.message.role", "assistant"), + str("llm.output_messages.0.message.content", "done"))); + assertThat(scalarWithStructure.output().path("value").isArray()).isTrue(); + assertThat(scalarWithStructure.output().path("messages").get(0).path("content").asText()) + .isEqualTo("done"); + + var textPlain = enrich(List.of( + str("openinference.span.kind", "CHAIN"), + str("output.mime_type", "text/plain"), + str("output.value", "plain output"))); + assertThat(textPlain.output().asText()).isEqualTo("plain output"); + + var unsupportedMime = enrich(List.of( + str("openinference.span.kind", "CHAIN"), + str("input.mime_type", "application/xml"), + str("input.value", ""))); + assertThat(unsupportedMime.input().asText()).isEqualTo(""); + + var nativeObject = enrich(List.of( + str("openinference.span.kind", "LLM"), + object("input.value", str("question", "native object"), str("messages", "raw collision")), + str("llm.input_messages.0.message.role", "user"), + str("llm.input_messages.0.message.content", "semantic wins"))); + assertThat(nativeObject.input().path("question").asText()).isEqualTo("native object"); + assertThat(nativeObject.input().path("messages").get(0).path("content").asText()) + .isEqualTo("semantic wins"); + + var nativeArray = enrich(List.of( + str("openinference.span.kind", "CHAIN"), + array("input.value", + AnyValue.newBuilder().setIntValue(1).build(), + AnyValue.newBuilder().setStringValue("two").build()))); + assertThat(nativeArray.input().isArray()).isTrue(); + assertThat(nativeArray.input().get(0).asInt()).isEqualTo(1); + assertThat(nativeArray.input().get(1).asText()).isEqualTo("two"); + } + + @Test + void normalizesCompletionParametersToolsAndLegacyFunctionCalls() { + var span = enrich(List.of( + str("llm.tools.8.tool.description", "Look up weather"), + str("llm.prompts.4.prompt.text", "Complete this sentence"), + str("llm.prompt_template.variables", "{\"city\":\"Paris\"}"), + str("llm.tools.8.tool.json_schema", "{\"type\":\"object\"}"), + str("llm.function_call", "{\"name\":\"weather\",\"arguments\":{\"city\":\"Paris\"}}"), + str("llm.invocation_parameters", "{\"temperature\":0.2}"), + str("llm.choices.2.completion.text", "It is sunny."), + str("llm.prompt_template.template", "Weather for {city}"), + str("llm.prompt_template.version", "v2"), + str("llm.tools.8.tool.name", "weather"), + str("llm.finish_reason", "stop"), + str("openinference.span.kind", "LLM"))); + + assertThat(span.input().path("tools").get(0).path("json_schema").path("type").asText()) + .isEqualTo("object"); + assertThat(span.input().path("prompts").get(0).path("text").asText()) + .isEqualTo("Complete this sentence"); + assertThat(span.input().path("invocation_parameters").path("temperature").asDouble()).isEqualTo(0.2); + assertThat(span.input().path("prompt_template").path("variables").path("city").asText()) + .isEqualTo("Paris"); + assertThat(span.output().path("choices").get(0).path("text").asText()).isEqualTo("It is sunny."); + assertThat(span.output().path("function_call").path("arguments").path("city").asText()) + .isEqualTo("Paris"); + assertThat(span.output().path("finish_reason").asText()).isEqualTo("stop"); + } + + @Test + void mapsSpanFieldsWithStablePriorityAndPreservesUnknownAttributesInMetadata() { + var attributes = new ArrayList<>(List.of( + str("llm.request.model_name", "requested-model"), + str("llm.model_name", "generic-model"), + str("llm.response.model_name", "actual-model"), + str("llm.system", "anthropic"), + str("llm.provider", "openai"), + integer("llm.token_count.prompt", 20), + integer("llm.token_count.completion", 8), + integer("llm.token_count.total", 28), + integer("llm.token_count.prompt_details.cache_read", 4), + integer("llm.token_count.prompt_details.cache_write", 2), + integer("llm.token_count.prompt_details.audio", 3), + integer("llm.token_count.completion_details.reasoning", 5), + integer("llm.token_count.completion_details.audio", 1), + decimal("llm.cost.total", 0.0125), + str("session.id", "session-fallback"), + str("thread_id", "explicit-thread"), + str("tag.tags", "[\"alpha\",\"beta\"]"), + str("opik.tags", "opik,beta"), + str("metadata", "{\"custom\":\"kept\",\"thread_id\":\"spoofed\",\"integration\":\"spoofed\"}"), + str("user.id", "user-7"), + str("llm.future.attribute", "future-value"), + str("openinference.span.kind", "LLM"))); + + var forward = enrich(attributes); + Collections.reverse(attributes); + var reverse = enrich(attributes); + + for (var span : List.of(forward, reverse)) { + assertThat(span.model()).isEqualTo("actual-model"); + assertThat(span.provider()).isEqualTo("openai"); + assertThat(span.type()).isEqualTo(SpanType.llm); + assertThat(span.usage()).containsEntry("prompt_tokens", 20) + .containsEntry("completion_tokens", 8) + .containsEntry("total_tokens", 28) + .containsEntry("cache_read_input_tokens", 4) + .containsEntry("cache_creation_input_tokens", 2) + .containsEntry("input_audio_tokens", 3) + .containsEntry("reasoning_tokens", 5) + .containsEntry("output_audio_tokens", 1); + assertThat(span.totalEstimatedCost()).isEqualByComparingTo("0.0125"); + assertThat(span.metadata().path("thread_id").asText()).isEqualTo("explicit-thread"); + assertThat(span.metadata().path("custom").asText()).isEqualTo("kept"); + assertThat(span.metadata().path("integration").asText()).isEqualTo("mixed-batch-scope"); + assertThat(span.metadata().path("user.id").asText()).isEqualTo("user-7"); + assertThat(span.metadata().path("openinference.span.kind").asText()).isEqualTo("LLM"); + assertThat(span.metadata().path("llm.future.attribute").asText()).isEqualTo("future-value"); + assertThat(span.tags()).containsExactlyInAnyOrder("alpha", "beta", "opik"); + assertThat(span.input()).isNull(); + } + + var sessionFallback = enrich(List.of( + str("openinference.span.kind", "CHAIN"), + str("session.id", "session-only"))); + assertThat(sessionFallback.metadata().path("thread_id").asText()).isEqualTo("session-only"); + + var aliasedProvider = enrich(List.of( + str("openinference.span.kind", "LLM"), + str("llm.system", "mistral_ai"), + str("llm.model_name", "mistral-small"))); + assertThat(aliasedProvider.provider()).isEqualTo("mistral"); + } + + @ParameterizedTest + @CsvSource({ + "LLM, llm", + "TOOL, tool", + "GUARDRAIL, guardrail", + "CHAIN, general", + "something-new, general" + }) + void mapsOpenInferenceKinds(String kind, SpanType expected) { + assertThat(enrich(List.of(str("openinference.span.kind", kind))).type()).isEqualTo(expected); + } + + @Test + void malformedIndicesAndValuesDoNotAbortIngestion() { + var span = enrich(List.of( + str("openinference.span.kind", "LLM"), + str("llm.input_messages.-1.message.content", "negative"), + str("llm.output_messages.not-a-number.message.content", "invalid"), + integer("llm.input_messages.0.message.role", 7), + integer("llm.output_messages.0.message.function_call_arguments_json", 8), + integer("llm.invocation_parameters", 9), + integer("llm.function_call", 10), + integer("llm.prompt_template.variables", 11), + integer("llm.tools.0.tool.json_schema", 12), + integer("llm.token_count.prompt", -1), + bool("tag.tags", true), + str("llm.tools.999999999999999999999.tool.name", "overflow"))); + + assertThat(span.input()).isNull(); + assertThat(span.output()).isNull(); + assertThat(span.metadata().fieldNames()).toIterable().contains( + "llm.input_messages.-1.message.content", + "llm.output_messages.not-a-number.message.content", + "llm.input_messages.0.message.role", + "llm.output_messages.0.message.function_call_arguments_json", + "llm.invocation_parameters", + "llm.function_call", + "llm.prompt_template.variables", + "llm.tools.0.tool.json_schema", + "llm.token_count.prompt", + "tag.tags", + "llm.tools.999999999999999999999.tool.name"); + + var malformedJsonStrings = enrich(List.of( + str("openinference.span.kind", "LLM"), + str("llm.invocation_parameters", "{broken"), + str("llm.function_call", "{also-broken"), + str("llm.prompt_template.variables", "{still-broken"), + str("llm.tools.0.tool.json_schema", "{schema-broken"))); + assertThat(malformedJsonStrings.input().path("invocation_parameters").asText()).isEqualTo("{broken"); + assertThat(malformedJsonStrings.input().path("prompt_template").path("variables").asText()) + .isEqualTo("{still-broken"); + assertThat(malformedJsonStrings.input().path("tools").get(0).path("json_schema").asText()) + .isEqualTo("{schema-broken"); + assertThat(malformedJsonStrings.output().path("function_call").asText()).isEqualTo("{also-broken"); + } + + @Test + void markerIsPerSpanAndUnmarkedFallbackRemainsCompatible() { + var flattenedMessage = str("llm.output_messages.0.message.content", "hello"); + + var marked = enrich(List.of(str("openinference.span.kind", "LLM"), flattenedMessage)); + var unmarked = enrich(List.of(flattenedMessage)); + var unmarkedInvocation = enrich(List.of(str("llm.invocation_parameters", "{\"temperature\":0.5}"))); + + assertThat(marked.output().path("messages").get(0).path("content").asText()).isEqualTo("hello"); + assertThat(marked.input()).isNull(); + assertThat(unmarked.input().path("llm.output_messages.0.message.content").asText()).isEqualTo("hello"); + assertThat(unmarked.output()).isNull(); + assertThat(unmarkedInvocation.input().path("llm.invocation_parameters").path("temperature").asDouble()) + .isEqualTo(0.5); + assertThat(unmarkedInvocation.type()).isEqualTo(SpanType.llm); + } + } } diff --git a/apps/opik-frontend/src/lib/openinference.ts b/apps/opik-frontend/src/lib/openinference.ts new file mode 100644 index 00000000000..85f6a5a4f6b --- /dev/null +++ b/apps/opik-frontend/src/lib/openinference.ts @@ -0,0 +1,691 @@ +export const OPENINFERENCE_SPAN_KIND = "openinference.span.kind"; + +export type OpenInferenceFieldType = "input" | "output"; + +export type OpenInferenceToolCall = { + id?: string; + function?: { + name?: string; + arguments?: string; + }; + reasoning_signature?: string; +}; + +export type OpenInferenceContent = { + type?: string; + text?: string; + id?: string; + signature?: string; + data?: string; + encrypted_content?: string; + image?: { url?: string }; + audio?: { url?: string; mime_type?: string; transcript?: string }; + tool_call?: OpenInferenceToolCall; +}; + +export type OpenInferenceMessage = { + role?: string; + content?: unknown; + contents?: OpenInferenceContent[]; + tool_calls?: OpenInferenceToolCall[]; + name?: string; + tool_call_id?: string; + function_call?: unknown; +}; + +export type ParsedOpenInferenceFields = { + inputMessages: OpenInferenceMessage[]; + outputMessages: OpenInferenceMessage[]; + prompts: string[]; + choices: string[]; + tools: unknown[]; + finishReason?: string; + functionCall?: unknown; + inputFallback?: unknown; + outputFallback?: unknown; + hasOpenInferenceData: boolean; +}; + +type UnknownRecord = Record; + +const MESSAGE_ATTRIBUTE_RE = + /^llm\.(input|output)_messages\.([^.]+)\.message\.(.+)$/; +const CONTENT_ATTRIBUTE_RE = /^contents\.([^.]+)\.(.+)$/; +const TOOL_CALL_ATTRIBUTE_RE = /^tool_calls\.([^.]+)\.tool_call\.(.+)$/; +const TOOL_ATTRIBUTE_RE = + /^llm\.tools\.([^.]+)\.tool\.(name|description|json_schema)$/; +const PROMPT_ATTRIBUTE_RE = /^llm\.prompts\.([^.]+)\.prompt\.text$/; +const CHOICE_ATTRIBUTE_RE = /^llm\.choices\.([^.]+)\.completion\.text$/; + +const DISPLAYABLE_LEGACY_PREFIXES = [ + "llm.input_messages.", + "llm.output_messages.", + "llm.prompts.", + "llm.choices.", + "llm.tools.", +]; + +const DISPLAYABLE_LEGACY_KEYS = new Set([ + OPENINFERENCE_SPAN_KIND, + "llm.finish_reason", + "llm.function_call", +]); + +const isRecord = (value: unknown): value is UnknownRecord => + value !== null && typeof value === "object" && !Array.isArray(value); + +const hasOwn = (value: UnknownRecord, key: string) => + Object.prototype.hasOwnProperty.call(value, key); + +const parseIndex = (value: string): number | undefined => { + if (!/^\d+$/.test(value)) return undefined; + const index = Number(value); + return Number.isSafeInteger(index) ? index : undefined; +}; + +const sortedValues = (values: Map): T[] => + [...values.entries()] + .sort(([left], [right]) => left - right) + .map(([, value]) => value); + +const toStringValue = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +const toArguments = (value: unknown): string | undefined => { + if (typeof value === "string") return value; + if (value === undefined) return undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +}; + +const parseMaybeJson = (value: unknown): unknown => { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +}; + +class ToolCallBuilder { + private readonly value: OpenInferenceToolCall = {}; + + accept(path: string, rawValue: unknown): boolean { + if (path === "id") { + const id = toStringValue(rawValue); + if (id === undefined) return false; + this.value.id = id; + return true; + } + if (path === "reasoning_signature") { + const signature = toStringValue(rawValue); + if (signature === undefined) return false; + this.value.reasoning_signature = signature; + return true; + } + if (path === "function.name") { + const name = toStringValue(rawValue); + if (name === undefined) return false; + this.value.function ??= {}; + this.value.function.name = name; + return true; + } + if (path === "function.arguments") { + const args = toArguments(rawValue); + if (args === undefined) return false; + this.value.function ??= {}; + this.value.function.arguments = args; + return true; + } + return false; + } + + build(): OpenInferenceToolCall | undefined { + return Object.keys(this.value).length > 0 ? this.value : undefined; + } +} + +class ContentBuilder { + private readonly value: OpenInferenceContent = {}; + private readonly toolCall = new ToolCallBuilder(); + + accept(path: string, rawValue: unknown): boolean { + const scalarFields: Record = { + "message_content.type": "type", + "message_content.text": "text", + "message_content.id": "id", + "message_content.signature": "signature", + "message_content.data": "data", + "message_content.encrypted_content": "encrypted_content", + }; + const scalarField = scalarFields[path]; + if (scalarField) { + const value = toStringValue(rawValue); + if (value === undefined) return false; + Object.assign(this.value, { [scalarField]: value }); + return true; + } + + if (path === "message_content.image.image.url") { + const url = toStringValue(rawValue); + if (url === undefined) return false; + this.value.image = { url }; + return true; + } + + const audioPrefix = "message_content.audio.audio."; + if (path.startsWith(audioPrefix)) { + const field = path.slice(audioPrefix.length); + if (!(["url", "mime_type", "transcript"] as string[]).includes(field)) + return false; + const value = toStringValue(rawValue); + if (value === undefined) return false; + this.value.audio ??= {}; + Object.assign(this.value.audio, { [field]: value }); + return true; + } + + if (path.startsWith("tool_call.")) { + return this.toolCall.accept(path.slice("tool_call.".length), rawValue); + } + return false; + } + + build(): OpenInferenceContent | undefined { + const toolCall = this.toolCall.build(); + if (toolCall) this.value.tool_call = toolCall; + return Object.keys(this.value).length > 0 ? this.value : undefined; + } +} + +class MessageBuilder { + private readonly value: OpenInferenceMessage = {}; + private readonly contents = new Map(); + private readonly toolCalls = new Map(); + private readonly functionCall: UnknownRecord = {}; + + accept(path: string, rawValue: unknown): boolean { + if (["role", "name", "tool_call_id"].includes(path)) { + const value = toStringValue(rawValue); + if (value === undefined) return false; + Object.assign(this.value, { [path]: value }); + return true; + } + if (path === "content") { + this.value.content = rawValue; + return true; + } + if (path === "function_call_name") { + const value = toStringValue(rawValue); + if (value === undefined) return false; + this.functionCall.name = value; + return true; + } + if (path === "function_call_arguments_json") { + this.functionCall.arguments = parseMaybeJson(rawValue); + return true; + } + + const contentMatch = path.match(CONTENT_ATTRIBUTE_RE); + if (contentMatch) { + const index = parseIndex(contentMatch[1]); + if (index === undefined) return false; + let content = this.contents.get(index); + if (!content) { + content = new ContentBuilder(); + this.contents.set(index, content); + } + return content.accept(contentMatch[2], rawValue); + } + + const toolCallMatch = path.match(TOOL_CALL_ATTRIBUTE_RE); + if (toolCallMatch) { + const index = parseIndex(toolCallMatch[1]); + if (index === undefined) return false; + let toolCall = this.toolCalls.get(index); + if (!toolCall) { + toolCall = new ToolCallBuilder(); + this.toolCalls.set(index, toolCall); + } + return toolCall.accept(toolCallMatch[2], rawValue); + } + return false; + } + + build(): OpenInferenceMessage | undefined { + const contents = sortedValues(this.contents) + .map((content) => content.build()) + .filter((content): content is OpenInferenceContent => Boolean(content)); + const toolCalls = sortedValues(this.toolCalls) + .map((toolCall) => toolCall.build()) + .filter((toolCall): toolCall is OpenInferenceToolCall => + Boolean(toolCall), + ); + if (contents.length > 0) this.value.contents = contents; + if (toolCalls.length > 0) this.value.tool_calls = toolCalls; + if (Object.keys(this.functionCall).length > 0) { + this.value.function_call = this.functionCall; + } + return Object.keys(this.value).length > 0 ? this.value : undefined; + } +} + +type LegacyAccumulator = { + inputMessages: Map; + outputMessages: Map; + prompts: Map; + choices: Map; + tools: Map; + finishReason?: string; + functionCall?: unknown; + found: boolean; +}; + +const createLegacyAccumulator = (): LegacyAccumulator => ({ + inputMessages: new Map(), + outputMessages: new Map(), + prompts: new Map(), + choices: new Map(), + tools: new Map(), + found: false, +}); + +const acceptLegacyAttribute = ( + accumulator: LegacyAccumulator, + key: string, + value: unknown, +) => { + const messageMatch = key.match(MESSAGE_ATTRIBUTE_RE); + if (messageMatch) { + accumulator.found = true; + const index = parseIndex(messageMatch[2]); + if (index === undefined) return; + const messages = + messageMatch[1] === "input" + ? accumulator.inputMessages + : accumulator.outputMessages; + let message = messages.get(index); + if (!message) { + message = new MessageBuilder(); + messages.set(index, message); + } + message.accept(messageMatch[3], value); + return; + } + + const toolMatch = key.match(TOOL_ATTRIBUTE_RE); + if (toolMatch) { + accumulator.found = true; + const index = parseIndex(toolMatch[1]); + if (index === undefined) return; + const tool = accumulator.tools.get(index) ?? {}; + tool[toolMatch[2]] = + toolMatch[2] === "json_schema" ? parseMaybeJson(value) : value; + accumulator.tools.set(index, tool); + return; + } + + const promptMatch = key.match(PROMPT_ATTRIBUTE_RE); + if (promptMatch) { + accumulator.found = true; + const index = parseIndex(promptMatch[1]); + const text = toStringValue(value); + if (index !== undefined && text !== undefined) + accumulator.prompts.set(index, text); + return; + } + + const choiceMatch = key.match(CHOICE_ATTRIBUTE_RE); + if (choiceMatch) { + accumulator.found = true; + const index = parseIndex(choiceMatch[1]); + const text = toStringValue(value); + if (index !== undefined && text !== undefined) + accumulator.choices.set(index, text); + return; + } + + if (key === "llm.finish_reason") { + accumulator.found = true; + accumulator.finishReason = toStringValue(value); + } else if (key === "llm.function_call") { + accumulator.found = true; + accumulator.functionCall = parseMaybeJson(value); + } +}; + +const collectLegacy = (accumulator: LegacyAccumulator, data: unknown): void => { + if (!isRecord(data)) return; + Object.entries(data).forEach(([key, value]) => + acceptLegacyAttribute(accumulator, key, value), + ); +}; + +const parseCanonicalToolCall = ( + value: unknown, +): OpenInferenceToolCall | undefined => { + if (!isRecord(value)) return undefined; + const toolCall: OpenInferenceToolCall = {}; + if (typeof value.id === "string") toolCall.id = value.id; + if (typeof value.reasoning_signature === "string") { + toolCall.reasoning_signature = value.reasoning_signature; + } + if (isRecord(value.function)) { + const fn: NonNullable = {}; + if (typeof value.function.name === "string") fn.name = value.function.name; + const args = toArguments(value.function.arguments); + if (args !== undefined) fn.arguments = args; + if (Object.keys(fn).length > 0) toolCall.function = fn; + } + return Object.keys(toolCall).length > 0 ? toolCall : undefined; +}; + +const parseCanonicalContent = ( + value: unknown, +): OpenInferenceContent | undefined => { + if (!isRecord(value)) return undefined; + const content: OpenInferenceContent = {}; + ( + ["type", "text", "id", "signature", "data", "encrypted_content"] as const + ).forEach((key) => { + const fieldValue = value[key]; + if (typeof fieldValue === "string") content[key] = fieldValue; + }); + if (isRecord(value.image) && typeof value.image.url === "string") { + content.image = { url: value.image.url }; + } + if (isRecord(value.audio)) { + const audioValue = value.audio; + const audio: NonNullable = {}; + (["url", "mime_type", "transcript"] as const).forEach((key) => { + const fieldValue = audioValue[key]; + if (typeof fieldValue === "string") audio[key] = fieldValue; + }); + if (Object.keys(audio).length > 0) content.audio = audio; + } + const toolCall = parseCanonicalToolCall(value.tool_call); + if (toolCall) content.tool_call = toolCall; + return Object.keys(content).length > 0 ? content : undefined; +}; + +const parseCanonicalMessage = ( + value: unknown, +): OpenInferenceMessage | undefined => { + if (!isRecord(value)) return undefined; + const message: OpenInferenceMessage = {}; + if (typeof value.role === "string") message.role = value.role; + if (hasOwn(value, "content")) message.content = value.content; + if (typeof value.name === "string") message.name = value.name; + if (typeof value.tool_call_id === "string") { + message.tool_call_id = value.tool_call_id; + } + if (Array.isArray(value.contents)) { + const contents = value.contents + .map(parseCanonicalContent) + .filter((item): item is OpenInferenceContent => Boolean(item)); + if (contents.length > 0) message.contents = contents; + } + if (Array.isArray(value.tool_calls)) { + const toolCalls = value.tool_calls + .map(parseCanonicalToolCall) + .filter((item): item is OpenInferenceToolCall => Boolean(item)); + if (toolCalls.length > 0) message.tool_calls = toolCalls; + } + if (hasOwn(value, "function_call")) { + message.function_call = value.function_call; + } + return Object.keys(message).length > 0 ? message : undefined; +}; + +const parseCanonicalMessages = (data: unknown): OpenInferenceMessage[] => { + if (!isRecord(data) || !Array.isArray(data.messages)) return []; + return data.messages + .map(parseCanonicalMessage) + .filter((message): message is OpenInferenceMessage => Boolean(message)); +}; + +const extractTexts = (data: unknown, key: "prompts" | "choices"): string[] => { + if (!isRecord(data) || !Array.isArray(data[key])) return []; + const items = data[key] as unknown[]; + const legacyKey = key === "prompts" ? "prompt.text" : "completion.text"; + return items + .map((item) => { + if (typeof item === "string") return item; + if (!isRecord(item)) return undefined; + return toStringValue(item.text) ?? toStringValue(item[legacyKey]); + }) + .filter((item): item is string => item !== undefined); +}; + +const extractFallback = (data: unknown): unknown => { + if (!isRecord(data)) return data; + return hasOwn(data, "value") ? data.value : undefined; +}; + +const dedupe = (values: T[]): T[] => { + const fingerprints = new Set(); + return values.filter((value) => { + let fingerprint: string; + try { + fingerprint = JSON.stringify(value) ?? String(value); + } catch { + return true; + } + if (fingerprints.has(fingerprint)) return false; + fingerprints.add(fingerprint); + return true; + }); +}; + +export const hasLegacyOpenInferenceAttributes = (data: unknown): boolean => { + if (!isRecord(data)) return false; + return Object.keys(data).some( + (key) => + DISPLAYABLE_LEGACY_KEYS.has(key) || + DISPLAYABLE_LEGACY_PREFIXES.some((prefix) => key.startsWith(prefix)), + ); +}; + +export const hasLegacyOpenInferenceOutputAttributes = ( + data: unknown, +): boolean => { + if (!isRecord(data)) return false; + return Object.keys(data).some( + (key) => + key.startsWith("llm.output_messages.") || + key.startsWith("llm.choices.") || + key === "llm.finish_reason" || + key === "llm.function_call", + ); +}; + +const hasCanonicalDisplayData = ( + data: unknown, + fieldType: OpenInferenceFieldType, +): boolean => { + if (!isRecord(data)) return false; + if (Array.isArray(data.messages) && data.messages.some(parseCanonicalMessage)) + return true; + if (fieldType === "input") { + return ( + extractTexts(data, "prompts").length > 0 || + (Array.isArray(data.tools) && data.tools.length > 0) + ); + } + return ( + extractTexts(data, "choices").length > 0 || hasOwn(data, "function_call") + ); +}; + +/** + * A hint selects OpenInference ahead of generic OpenAI detection, but does not make a raw + * {@code {value: ...}} object displayable on its own. + */ +export const isOpenInferenceField = ( + data: unknown, + fieldType: OpenInferenceFieldType, + hinted: boolean, +): boolean => + hasLegacyOpenInferenceAttributes(data) || + (hinted && hasCanonicalDisplayData(data, fieldType)); + +export const hasOpenInferenceHint = ( + metadata: unknown, + input: unknown, + output?: unknown, +): boolean => { + const metadataMarker = + isRecord(metadata) && hasOwn(metadata, OPENINFERENCE_SPAN_KIND); + return ( + metadataMarker || + hasLegacyOpenInferenceAttributes(input) || + hasLegacyOpenInferenceAttributes(output) + ); +}; + +export const parseOpenInferenceFields = ( + input: unknown, + output: unknown, +): ParsedOpenInferenceFields => { + const legacy = createLegacyAccumulator(); + collectLegacy(legacy, input); + collectLegacy(legacy, output); + + const canonicalInputMessages = parseCanonicalMessages(input); + const canonicalOutputMessages = parseCanonicalMessages(output); + const legacyInputMessages = sortedValues(legacy.inputMessages) + .map((message) => message.build()) + .filter((message): message is OpenInferenceMessage => Boolean(message)); + const legacyOutputMessages = sortedValues(legacy.outputMessages) + .map((message) => message.build()) + .filter((message): message is OpenInferenceMessage => Boolean(message)); + + const inputRecord = isRecord(input) ? input : undefined; + const outputRecord = isRecord(output) ? output : undefined; + const prompts = dedupe([ + ...extractTexts(input, "prompts"), + ...sortedValues(legacy.prompts), + ]); + const choices = dedupe([ + ...extractTexts(output, "choices"), + ...sortedValues(legacy.choices), + ]); + const canonicalTools = + inputRecord && Array.isArray(inputRecord.tools) ? inputRecord.tools : []; + const tools = dedupe([...canonicalTools, ...sortedValues(legacy.tools)]); + const finishReason = + (outputRecord && toStringValue(outputRecord.finish_reason)) ?? + legacy.finishReason; + const functionCall = + (outputRecord && outputRecord.function_call) ?? legacy.functionCall; + + return { + inputMessages: dedupe([...canonicalInputMessages, ...legacyInputMessages]), + outputMessages: dedupe([ + ...canonicalOutputMessages, + ...legacyOutputMessages, + ]), + prompts, + choices, + tools, + finishReason, + functionCall, + inputFallback: extractFallback(input), + outputFallback: extractFallback(output), + hasOpenInferenceData: + legacy.found || + hasLegacyOpenInferenceAttributes(input) || + hasLegacyOpenInferenceAttributes(output) || + hasCanonicalDisplayData(input, "input") || + hasCanonicalDisplayData(output, "output"), + }; +}; + +const messageText = ( + message: OpenInferenceMessage | undefined, +): string | undefined => { + if (!message) return undefined; + if (typeof message.content === "string" && message.content.length > 0) { + return message.content; + } + if (message.contents) { + const visible = message.contents + .map((content) => content.text ?? content.audio?.transcript) + .filter((value): value is string => Boolean(value)); + if (visible.length > 0) return visible.join("\n\n"); + } + return undefined; +}; + +const extractParsedPrettyText = ( + parsed: ParsedOpenInferenceFields, + fieldType: OpenInferenceFieldType, +): string | undefined => { + const messages = + fieldType === "input" ? parsed.inputMessages : parsed.outputMessages; + const preferredRoles = + fieldType === "input" + ? new Set(["user", "human"]) + : new Set(["assistant", "model", "ai", "agent"]); + const preferred = [...messages] + .reverse() + .find( + (message) => + message.role && preferredRoles.has(message.role.toLowerCase()), + ); + const text = messageText(preferred ?? messages[messages.length - 1]); + if (text) return text; + + const completions = fieldType === "input" ? parsed.prompts : parsed.choices; + if (completions.length > 0) return completions[completions.length - 1]; + + const fallback = + fieldType === "input" ? parsed.inputFallback : parsed.outputFallback; + return typeof fallback === "string" ? fallback : undefined; +}; + +/** Pure short-text extraction shared by table/annotation/export Pretty ✨ views. */ +export const extractOpenInferencePrettyText = ( + data: unknown, + fieldType: OpenInferenceFieldType, +): string | undefined => { + const hasRoleBasedMessages = + isRecord(data) && + Array.isArray(data.messages) && + data.messages.some( + (message) => isRecord(message) && typeof message.role === "string", + ); + const hasCompletionData = + isRecord(data) && + (extractTexts(data, "prompts").length > 0 || + extractTexts(data, "choices").length > 0); + if ( + !hasRoleBasedMessages && + !hasCompletionData && + !hasLegacyOpenInferenceAttributes(data) + ) { + return undefined; + } + + return extractParsedPrettyText( + parseOpenInferenceFields( + fieldType === "input" ? data : undefined, + fieldType === "output" ? data : undefined, + ), + fieldType, + ); +}; + +/** Recovers output attributes that older ingestion stored alongside the input raw value. */ +export const extractLegacyOpenInferenceOutputText = ( + storedInput: unknown, +): string | undefined => { + if (!hasLegacyOpenInferenceOutputAttributes(storedInput)) return undefined; + return extractParsedPrettyText( + parseOpenInferenceFields(storedInput, undefined), + "output", + ); +}; diff --git a/apps/opik-frontend/src/lib/traces.test.ts b/apps/opik-frontend/src/lib/traces.test.ts index 63753e0e9d2..013447df715 100644 --- a/apps/opik-frontend/src/lib/traces.test.ts +++ b/apps/opik-frontend/src/lib/traces.test.ts @@ -538,4 +538,110 @@ describe("prettifyMessage", () => { prettified: true, }); }); + + it("prettifies canonical OpenInference output messages", () => { + const result = prettifyMessage( + { + messages: [ + { + role: "model", + contents: [ + { type: "reasoning", text: "Check the facts" }, + { type: "text", text: "The answer is 42" }, + ], + }, + ], + }, + { type: "output" }, + ); + + expect(result).toEqual({ + message: "Check the facts\n\nThe answer is 42", + prettified: true, + }); + }); + + it("prettifies OpenInference completion prompts and choices", () => { + expect( + prettifyMessage( + { prompts: [{ text: "First" }, { text: "Latest prompt" }] }, + { type: "input" }, + ), + ).toEqual({ message: "Latest prompt", prettified: true }); + expect( + prettifyMessage( + { choices: [{ text: "One" }, { text: "Final completion" }] }, + { type: "output" }, + ), + ).toEqual({ message: "Final completion", prettified: true }); + }); + + it("prettifies historical flattened OpenInference output stored in input", () => { + const storedInput = { + "openinference.span.kind": "LLM", + "llm.input_messages.0.message.role": "user", + "llm.input_messages.0.message.content": "Question", + "llm.output_messages.4.message.role": "assistant", + "llm.output_messages.4.message.content": "Recovered answer", + }; + + expect(prettifyMessage(storedInput, { type: "output" })).toEqual({ + message: "Recovered answer", + prettified: true, + }); + + expect( + prettifyMessage(undefined, { + type: "output", + openInferenceInput: storedInput, + }), + ).toEqual({ + message: "Recovered answer", + prettified: true, + }); + + expect( + prettifyMessage( + { value: { choices: [{ text: "Less precise raw answer" }] } }, + { type: "output", openInferenceInput: storedInput }, + ), + ).toEqual({ + message: "Recovered answer", + prettified: true, + }); + }); + + it("does not replace canonical output with canonical input messages", () => { + expect( + prettifyMessage( + { + messages: [{ role: "assistant", content: "Canonical answer" }], + }, + { + type: "output", + openInferenceInput: { + messages: [{ role: "tool", content: "Tool result" }], + }, + }, + ), + ).toEqual({ + message: "Canonical answer", + prettified: true, + }); + }); + + it("does not treat the legacy input raw value as recovered output", () => { + expect( + prettifyMessage("Actual answer", { + type: "output", + openInferenceInput: { + value: "Question", + "llm.finish_reason": "stop", + }, + }), + ).toEqual({ + message: "Actual answer", + prettified: true, + }); + }); }); diff --git a/apps/opik-frontend/src/lib/traces.ts b/apps/opik-frontend/src/lib/traces.ts index 27be4a44ab6..41657abeff0 100644 --- a/apps/opik-frontend/src/lib/traces.ts +++ b/apps/opik-frontend/src/lib/traces.ts @@ -11,6 +11,10 @@ import { ExperimentItem } from "@/types/datasets"; import { Thread, TRACE_VISIBILITY_MODE } from "@/types/traces"; import { safelyParseJSON } from "@/lib/utils"; import isEmpty from "lodash/isEmpty"; +import { + extractLegacyOpenInferenceOutputText, + extractOpenInferencePrettyText, +} from "@/lib/openinference"; const MESSAGES_DIVIDER = `\n\n ----------------- \n\n`; @@ -41,6 +45,7 @@ export const traceVisible = (item: ExperimentItem) => type PrettifyMessageConfig = { type: "input" | "output"; + openInferenceInput?: object | string; }; type PrettifyMessageResponse = { @@ -613,6 +618,17 @@ export const prettifyMessage = ( type: "input", }, ): PrettifyMessageResponse => { + const recoveredOpenInferenceOutput = + config.type === "output" + ? extractLegacyOpenInferenceOutputText(config.openInferenceInput) + : undefined; + if (isString(recoveredOpenInferenceOutput)) { + return { + message: recoveredOpenInferenceOutput, + prettified: true, + }; + } + if (isString(message)) { const extracted = extractTextFieldFromTruncatedJson(message, config); return { @@ -621,7 +637,11 @@ export const prettifyMessage = ( } as PrettifyMessageResponse; } try { - let processedMessage = prettifyOpenAIMessageLogic(message, config); + let processedMessage = extractOpenInferencePrettyText(message, config.type); + + if (!isString(processedMessage)) { + processedMessage = prettifyOpenAIMessageLogic(message, config); + } if (!isString(processedMessage)) { processedMessage = prettifyOpenAIAgentsMessageLogic(message, config); diff --git a/apps/opik-frontend/src/shared/DataTableCells/PrettyCell.tsx b/apps/opik-frontend/src/shared/DataTableCells/PrettyCell.tsx index cc5d2a417a7..2bd6b3ef67b 100644 --- a/apps/opik-frontend/src/shared/DataTableCells/PrettyCell.tsx +++ b/apps/opik-frontend/src/shared/DataTableCells/PrettyCell.tsx @@ -26,11 +26,15 @@ const PrettyCell = (context: CellContext) => { const { fieldType = "input", colorIndicator = false } = (custom ?? {}) as CustomMeta; const value = context.getValue() as string | object | undefined | null; + const rowInput = (context.row.original as { input?: object | string })?.input; const displayMessage = useMemo(() => { - if (!value) return "-"; + const pretty = prettifyMessage(value ?? undefined, { + type: fieldType, + openInferenceInput: fieldType === "output" ? rowInput : undefined, + }); - const pretty = prettifyMessage(value, { type: fieldType }); + if (!pretty.message) return "-"; let message: string; if (isObject(pretty.message)) { @@ -44,7 +48,7 @@ const PrettyCell = (context: CellContext) => { } return message; - }, [value, fieldType, truncationEnabled, maxDataLength]); + }, [value, fieldType, rowInput, truncationEnabled, maxDataLength]); const rowHeight = context.column.columnDef.meta?.overrideRowHeight ?? diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/detectLLMMessages.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/detectLLMMessages.ts index f47836d5284..d262f2e594b 100644 --- a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/detectLLMMessages.ts +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/detectLLMMessages.ts @@ -16,7 +16,7 @@ import { getFormat, getAllFormats } from "./providers/registry"; export const detectLLMMessages = ( data: unknown, prettifyConfig?: { fieldType?: "input" | "output" }, - formatHint?: string, + formatHint?: LLMMessageFormat, ): LLMMessageFormatDetectionResult => { const isEmpty = data == null || @@ -28,8 +28,8 @@ export const detectLLMMessages = ( // If format hint provided, try that first if (formatHint) { - const format = getFormat(formatHint as LLMMessageFormat); - if (format && format.detector(data, prettifyConfig)) { + const format = getFormat(formatHint); + if (format && format.detector(data, { ...prettifyConfig, formatHint })) { return { supported: true, format: format.name, @@ -41,7 +41,7 @@ export const detectLLMMessages = ( // Auto-detect by trying all formats const formats = getAllFormats(); for (const format of formats) { - if (format.detector(data, prettifyConfig)) { + if (format.detector(data, { ...prettifyConfig, formatHint })) { return { supported: true, format: format.name, diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/index.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/index.ts index 8fa9f0e1952..618dc3e909f 100644 --- a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/index.ts +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/index.ts @@ -9,6 +9,7 @@ export type { LLMMapperResult, FormatDetector, FormatMapper, + LLMMessagePrettifyConfig, LLMMessageFormatImplementation, } from "./types"; diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/mapAndCombineMessages.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/mapAndCombineMessages.ts index 88585795dd8..17d993e725e 100644 --- a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/mapAndCombineMessages.ts +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/mapAndCombineMessages.ts @@ -4,17 +4,68 @@ import { LLMMessageDescriptor, LLMMapperResult, LLMMessageFormatDetectionResult, + LLMMessageFormat, } from "./types"; +import { PrettyLLMMessageUsageProps } from "../types"; export function mapAndCombineMessages( input: unknown, output: unknown, + formatHint?: LLMMessageFormat, + spanUsage?: PrettyLLMMessageUsageProps["usage"], ): LLMMapperResult { - const inputDetection = detectLLMMessages(input, { fieldType: "input" }); - const outputDetection = detectLLMMessages(output, { fieldType: "output" }); + const inputDetection = detectLLMMessages( + input, + { fieldType: "input" }, + formatHint, + ); + const outputDetection = detectLLMMessages( + output, + { fieldType: "output" }, + formatHint, + ); - const inputResult = mapForDetection(input, inputDetection, "input"); - const outputResult = mapForDetection(output, outputDetection, "output"); + // Historical OpenInference spans can have every flattened output attribute in input, + // while output contains only a raw {value, mime_type} fallback. Once either side proves + // the format, let its pair-aware combiner inspect both raw fields. + if ( + inputDetection.format === "openinference" || + outputDetection.format === "openinference" + ) { + const format = getFormat("openinference"); + if (format?.combiner) { + const mapped = format.combiner( + { + raw: input, + mapped: format.mapper(input, { + fieldType: "input", + formatHint, + }), + }, + { + raw: output, + mapped: format.mapper(output, { + fieldType: "output", + formatHint, + }), + }, + ); + return { ...mapped, usage: spanUsage ?? mapped.usage }; + } + } + + const inputResult = mapForDetection( + input, + inputDetection, + "input", + formatHint, + ); + const outputResult = mapForDetection( + output, + outputDetection, + "output", + formatHint, + ); if ( inputDetection.supported && @@ -41,9 +92,10 @@ function mapForDetection( data: unknown, detection: LLMMessageFormatDetectionResult, fieldType: "input" | "output", + formatHint?: LLMMessageFormat, ): LLMMapperResult | null { if (!detection.supported || !detection.format) return null; const format = getFormat(detection.format); if (!format) return null; - return format.mapper(data, { fieldType }); + return format.mapper(data, { fieldType, formatHint }); } diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/index.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/index.ts index f80ceb3e260..41ba246bbd3 100644 --- a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/index.ts +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/index.ts @@ -1,2 +1,8 @@ export { openaiFormat, detectOpenAIFormat, mapOpenAIMessages } from "./openai"; +export { + openinferenceFormat, + detectOpenInferenceFormat, + mapOpenInferenceMessages, + combineOpenInferenceMessages, +} from "./openinference"; export { getFormat, getAllFormats } from "./registry"; diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/detector.test.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/detector.test.ts new file mode 100644 index 00000000000..36956c2e64a --- /dev/null +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/detector.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { detectLLMMessages } from "../../detectLLMMessages"; +import { detectOpenInferenceFormat } from "./detector"; + +describe("detectOpenInferenceFormat", () => { + it("detects historical flattened attributes without a hint", () => { + expect( + detectOpenInferenceFormat( + { + "openinference.span.kind": "LLM", + "llm.input_messages.0.message.role": "user", + }, + { fieldType: "input" }, + ), + ).toBe(true); + }); + + it("detects a historical legacy function call without a marker", () => { + expect( + detectOpenInferenceFormat( + { "llm.function_call": '{"name":"weather"}' }, + { fieldType: "output" }, + ), + ).toBe(true); + }); + + it("uses a marker-derived hint for the canonical shape", () => { + expect( + detectLLMMessages( + { messages: [{ role: "human", content: "hello" }] }, + { fieldType: "input" }, + "openinference", + ), + ).toMatchObject({ + supported: true, + format: "openinference", + confidence: "high", + }); + }); + + it("does not accept an arbitrary raw value just because it is hinted", () => { + expect( + detectOpenInferenceFormat( + { value: "not enough to identify OpenInference" }, + { fieldType: "input", formatHint: "openinference" }, + ), + ).toBe(false); + }); + + it("rejects canonical-looking data without a marker-derived hint", () => { + expect( + detectOpenInferenceFormat( + { messages: [{ role: "user", content: "hello" }] }, + { fieldType: "input" }, + ), + ).toBe(false); + }); +}); diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/detector.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/detector.ts new file mode 100644 index 00000000000..c0732305ba3 --- /dev/null +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/detector.ts @@ -0,0 +1,16 @@ +import { isOpenInferenceField } from "@/lib/openinference"; +import { FormatDetector } from "../../types"; + +export const detectOpenInferenceFormat: FormatDetector = ( + data, + prettifyConfig, +) => { + const fieldType = prettifyConfig?.fieldType; + if (!fieldType) return false; + + return isOpenInferenceField( + data, + fieldType, + prettifyConfig?.formatHint === "openinference", + ); +}; diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/index.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/index.ts new file mode 100644 index 00000000000..e921ef00dac --- /dev/null +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/index.ts @@ -0,0 +1,19 @@ +import { LLMMessageFormatImplementation } from "../../types"; +import { detectOpenInferenceFormat } from "./detector"; +import { + combineOpenInferenceMessages, + mapOpenInferenceMessages, +} from "./mapper"; + +export const openinferenceFormat: LLMMessageFormatImplementation = { + name: "openinference", + detector: detectOpenInferenceFormat, + mapper: mapOpenInferenceMessages, + combiner: combineOpenInferenceMessages, +}; + +export { + combineOpenInferenceMessages, + detectOpenInferenceFormat, + mapOpenInferenceMessages, +}; diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/mapper.test.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/mapper.test.ts new file mode 100644 index 00000000000..b282483cd59 --- /dev/null +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/mapper.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { mapAndCombineMessages } from "../../mapAndCombineMessages"; +import { + combineOpenInferenceMessages, + mapOpenInferenceMessages, +} from "./mapper"; + +describe("OpenInference message mapping", () => { + it("maps canonical chat, roles, tools, usage and ordered multimodal content", () => { + const toolCall = { + id: "call-1", + function: { name: "weather", arguments: '{"city":"Paris"}' }, + reasoning_signature: "opaque-tool-signature", + }; + const input = { + messages: [ + { role: "developer", content: "Be concise" }, + { role: "human", content: "What is the weather?" }, + ], + prompts: [{ text: "Weather for Paris" }], + tools: [{ name: "weather", json_schema: { type: "object" } }], + }; + const output = { + messages: [ + { + role: "model", + contents: [ + { + type: "reasoning", + text: "I should call the tool", + encrypted_content: "kept-in-details", + }, + { type: "text", text: "Checking now." }, + { type: "image", image: { url: "[image_0]" } }, + { + type: "audio", + audio: { + url: "https://example.test/answer.wav", + mime_type: "audio/wav", + transcript: "Checking now", + }, + }, + { + type: "tool_use", + tool_call: { + function: toolCall.function, + reasoning_signature: toolCall.reasoning_signature, + }, + }, + ], + tool_calls: [toolCall], + }, + ], + finish_reason: "tool_calls", + }; + + const result = mapAndCombineMessages(input, output, "openinference", { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }); + + expect(result.messages.map((message) => message.role)).toEqual([ + "system", + "user", + "user", + "system", + "assistant", + ]); + const outputMessage = result.messages.at(-1)!; + expect(outputMessage.blocks.map((block) => block.blockType)).toEqual([ + "text", + "text", + "image", + "audio", + "text", + "code", + ]); + expect( + outputMessage.blocks.filter((block) => block.blockType === "code"), + ).toHaveLength(1); + expect(outputMessage.finishReason).toBe("tool_calls"); + expect(result.usage).toEqual({ + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }); + }); + + it("maps completion prompts, choices and a legacy function call", () => { + const result = combineOpenInferenceMessages( + { + raw: { + prompts: [{ text: "Complete me" }], + tools: [ + { + json_schema: { + type: "function", + function: { name: "calculator", description: "Calculate" }, + }, + }, + ], + }, + mapped: { messages: [] }, + }, + { + raw: { + choices: [{ text: "Completed" }], + function_call: { name: "calculator", arguments: { x: 2 } }, + finish_reason: "stop", + }, + mapped: { messages: [] }, + }, + ); + + expect(result.messages.map((message) => message.label)).toEqual([ + "Prompt", + "Available tools", + "Completion", + ]); + expect(result.messages[1].blocks[0].props).toMatchObject({ + label: "calculator", + }); + expect(result.messages.at(-1)?.finishReason).toBe("stop"); + }); + + it("restores the exact historical storage shape and removes raw output duplication", () => { + const legacyInput = { + "openinference.span.kind": "LLM", + value: { prompt: "raw request" }, + mime_type: "application/json", + "llm.input_messages.3.message.role": "human", + "llm.input_messages.3.message.content": "Hello", + "llm.output_messages.8.message.role": "model", + "llm.output_messages.8.message.content": "Semantic answer", + "llm.output_messages.8.message.tool_calls.4.tool_call.id": "call-4", + "llm.output_messages.8.message.tool_calls.4.tool_call.function.name": + "search", + "llm.output_messages.8.message.tool_calls.4.tool_call.function.arguments": + { + query: "Opik", + }, + "llm.finish_reason": "stop", + }; + const legacyOutput = { + value: { + choices: [ + { message: { role: "assistant", content: "Semantic answer" } }, + ], + }, + mime_type: "application/json", + }; + + const result = mapAndCombineMessages( + legacyInput, + legacyOutput, + "openinference", + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0]).toMatchObject({ + id: "openinference-input-0", + role: "user", + }); + expect(result.messages[1]).toMatchObject({ + id: "openinference-output-0", + role: "assistant", + finishReason: "stop", + }); + expect( + result.messages.some((message) => message.id.includes("fallback")), + ).toBe(false); + const code = result.messages[1].blocks.find( + (block) => block.blockType === "code", + ); + expect(code?.props).toMatchObject({ + label: "search", + code: '{\n "query": "Opik"\n}', + }); + }); + + it("uses old value/mime_type as raw fallback when no semantic output exists", () => { + const result = mapAndCombineMessages( + { + "openinference.span.kind": "LLM", + "llm.input_messages.0.message.role": "user", + "llm.input_messages.0.message.content": "Hello", + }, + { value: "Raw answer", mime_type: "text/plain" }, + "openinference", + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].id).toBe("openinference-output-fallback-0"); + expect(result.messages[1].blocks[0].props).toMatchObject({ + children: "Raw answer", + }); + }); + + it("handles malformed and partial messages without throwing", () => { + expect(() => + mapOpenInferenceMessages( + { + messages: [ + { role: 42, contents: [{ type: "image", image: {} }] }, + { role: "unknown-role", contents: [null, { type: "text" }] }, + ], + }, + { fieldType: "output", formatHint: "openinference" }, + ), + ).not.toThrow(); + + const result = mapOpenInferenceMessages( + { messages: [{ role: "unknown-role", content: "safe" }] }, + { fieldType: "output", formatHint: "openinference" }, + ); + expect(result.messages[0].role).toBe("assistant"); + }); +}); diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/mapper.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/mapper.ts new file mode 100644 index 00000000000..4b0a6184692 --- /dev/null +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/openinference/mapper.ts @@ -0,0 +1,342 @@ +import { + OpenInferenceContent, + OpenInferenceMessage, + OpenInferenceToolCall, + parseOpenInferenceFields, + ParsedOpenInferenceFields, +} from "@/lib/openinference"; +import PrettyLLMMessage from "@/shared/PrettyLLMMessage"; +import { MessageRole } from "@/shared/PrettyLLMMessage/types"; +import { + FormatCombiner, + FormatMapper, + LLMBlockDescriptor, + LLMMessageDescriptor, + LLMMapperResult, +} from "../../types"; +import { isPlaceholder } from "../../utils"; + +const normalizeRole = ( + role: string | undefined, + fieldType: "input" | "output", +): MessageRole => { + switch (role?.toLowerCase()) { + case "assistant": + case "model": + case "ai": + case "agent": + return "assistant"; + case "system": + case "developer": + return "system"; + case "tool": + case "function": + return "tool"; + case "user": + case "human": + return "user"; + default: + return fieldType === "output" ? "assistant" : "user"; + } +}; + +const formatCode = (value: unknown): string => { + if (typeof value === "string") { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const mediaName = ( + url: string, + type: "Image" | "Audio", + index: number, +): string => { + if (isPlaceholder(url) || url.startsWith("data:")) { + return isPlaceholder(url) ? url : `${type} ${index + 1}`; + } + try { + return new URL(url).pathname.split("/").pop() || `${type} ${index + 1}`; + } catch { + return `${type} ${index + 1}`; + } +}; + +const textBlock = (text: string, role: MessageRole): LLMBlockDescriptor => ({ + blockType: "text", + component: PrettyLLMMessage.TextBlock, + props: { children: text, role, showMoreButton: true }, +}); + +const codeBlock = (value: unknown, label: string): LLMBlockDescriptor => ({ + blockType: "code", + component: PrettyLLMMessage.CodeBlock, + props: { code: formatCode(value), label }, +}); + +const toolCallFingerprints = (toolCall: OpenInferenceToolCall): string[] => { + const fingerprints: string[] = []; + if (toolCall.id) fingerprints.push(`id:${toolCall.id}`); + if (toolCall.function?.name || toolCall.function?.arguments) { + fingerprints.push( + `function:${toolCall.function?.name ?? ""}:${ + toolCall.function?.arguments ?? "" + }`, + ); + } + if (toolCall.reasoning_signature) { + fingerprints.push(`reasoning:${toolCall.reasoning_signature}`); + } + return fingerprints; +}; + +const toolCallBlock = (toolCall: OpenInferenceToolCall): LLMBlockDescriptor => + codeBlock( + toolCall.function?.arguments ?? "", + toolCall.function?.name ?? "Tool call", + ); + +const contentBlocks = ( + contents: OpenInferenceContent[], + role: MessageRole, +): { blocks: LLMBlockDescriptor[]; orderedToolCalls: Set } => { + const blocks: LLMBlockDescriptor[] = []; + const orderedToolCalls = new Set(); + + contents.forEach((content, index) => { + switch (content.type) { + case "image": { + const url = content.image?.url; + if (url) { + blocks.push({ + blockType: "image", + component: PrettyLLMMessage.ImageBlock, + props: { images: [{ url, name: mediaName(url, "Image", index) }] }, + }); + } + break; + } + case "audio": { + const url = content.audio?.url; + if (url) { + blocks.push({ + blockType: "audio", + component: PrettyLLMMessage.AudioPlayerBlock, + props: { audios: [{ url, name: mediaName(url, "Audio", index) }] }, + }); + } + if (content.audio?.transcript) { + blocks.push(textBlock(content.audio.transcript, role)); + } + break; + } + case "tool_use": { + if (content.tool_call) { + toolCallFingerprints(content.tool_call).forEach((fingerprint) => + orderedToolCalls.add(fingerprint), + ); + blocks.push(toolCallBlock(content.tool_call)); + } + break; + } + case "reasoning": + case "text": + default: + if (content.text) blocks.push(textBlock(content.text, role)); + } + }); + + return { blocks, orderedToolCalls }; +}; + +const mapMessage = ( + message: OpenInferenceMessage, + index: number, + fieldType: "input" | "output", +): LLMMessageDescriptor => { + const role = normalizeRole(message.role, fieldType); + const blocks: LLMBlockDescriptor[] = []; + + if (message.contents) { + const mappedContents = contentBlocks(message.contents, role); + blocks.push(...mappedContents.blocks); + message.tool_calls + ?.filter( + (toolCall) => + !toolCallFingerprints(toolCall).some((fingerprint) => + mappedContents.orderedToolCalls.has(fingerprint), + ), + ) + .forEach((toolCall) => blocks.push(toolCallBlock(toolCall))); + } else { + if (message.content !== undefined && message.content !== null) { + blocks.push( + role === "tool" + ? codeBlock(message.content, message.name ?? "Tool result") + : typeof message.content === "string" + ? textBlock(message.content, role) + : codeBlock(message.content, "Content"), + ); + } + message.tool_calls?.forEach((toolCall) => + blocks.push(toolCallBlock(toolCall)), + ); + } + + if (message.function_call !== undefined) { + blocks.push(codeBlock(message.function_call, "Function call")); + } + + return { + id: `openinference-${fieldType}-${index}`, + role, + label: role === "tool" ? message.name ?? message.tool_call_id : undefined, + blocks, + }; +}; + +const fallbackMessage = ( + value: unknown, + fieldType: "input" | "output", + index: number, +): LLMMessageDescriptor => { + const role: MessageRole = fieldType === "output" ? "assistant" : "user"; + return { + id: `openinference-${fieldType}-fallback-${index}`, + role, + blocks: [ + typeof value === "string" + ? textBlock(value, role) + : codeBlock(value, fieldType === "output" ? "Output" : "Input"), + ], + }; +}; + +const toolLabel = (tool: unknown, index: number): string => { + if (typeof tool !== "object" || tool === null) return `Tool ${index + 1}`; + if ("name" in tool && typeof tool.name === "string") return tool.name; + if (!("json_schema" in tool)) return `Tool ${index + 1}`; + + let schema: unknown = tool.json_schema; + if (typeof schema === "string") { + try { + schema = JSON.parse(schema); + } catch { + return `Tool ${index + 1}`; + } + } + if (typeof schema !== "object" || schema === null) return `Tool ${index + 1}`; + if ("name" in schema && typeof schema.name === "string") return schema.name; + if ( + "function" in schema && + typeof schema.function === "object" && + schema.function !== null && + "name" in schema.function && + typeof schema.function.name === "string" + ) { + return schema.function.name; + } + return `Tool ${index + 1}`; +}; + +const mapParsed = (parsed: ParsedOpenInferenceFields): LLMMapperResult => { + const messages: LLMMessageDescriptor[] = parsed.inputMessages.map( + (message, index) => mapMessage(message, index, "input"), + ); + + if (parsed.inputMessages.length === 0 && parsed.inputFallback !== undefined) { + messages.push(fallbackMessage(parsed.inputFallback, "input", 0)); + } + + parsed.prompts.forEach((prompt, index) => { + messages.push({ + id: `openinference-prompt-${index}`, + role: "user", + label: "Prompt", + blocks: [textBlock(prompt, "user")], + }); + }); + + if (parsed.tools.length > 0) { + messages.push({ + id: "openinference-tools", + role: "system", + label: "Available tools", + blocks: parsed.tools.map((tool, index) => + codeBlock(tool, toolLabel(tool, index)), + ), + }); + } + + const outputStart = messages.length; + messages.push( + ...parsed.outputMessages.map((message, index) => + mapMessage(message, index, "output"), + ), + ); + + parsed.choices.forEach((choice, index) => { + messages.push({ + id: `openinference-choice-${index}`, + role: "assistant", + label: "Completion", + blocks: [textBlock(choice, "assistant")], + }); + }); + + if (parsed.functionCall !== undefined) { + if (messages.length > outputStart) { + messages[messages.length - 1].blocks.push( + codeBlock(parsed.functionCall, "Function call"), + ); + } else { + messages.push({ + id: "openinference-function-call", + role: "assistant", + blocks: [codeBlock(parsed.functionCall, "Function call")], + }); + } + } + + // A historical output.value often duplicates the richer llm.output_messages.* + // attributes that were incorrectly stored in input. Use raw output only as fallback. + if ( + parsed.outputMessages.length === 0 && + parsed.choices.length === 0 && + parsed.functionCall === undefined && + parsed.outputFallback !== undefined + ) { + messages.push(fallbackMessage(parsed.outputFallback, "output", 0)); + } + + if (parsed.finishReason && messages.length > outputStart) { + messages[messages.length - 1].finishReason = parsed.finishReason; + } + + return { messages }; +}; + +export const mapOpenInferenceMessages: FormatMapper = ( + data, + prettifyConfig, +) => { + const fieldType = prettifyConfig?.fieldType; + if (!fieldType) return { messages: [] }; + return mapParsed( + parseOpenInferenceFields( + fieldType === "input" ? data : undefined, + fieldType === "output" ? data : undefined, + ), + ); +}; + +export const combineOpenInferenceMessages: FormatCombiner = (input, output) => + mapParsed(parseOpenInferenceFields(input.raw, output.raw)); diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/registry.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/registry.ts index a47c293ff9b..f4aa1e79752 100644 --- a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/registry.ts +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/providers/registry.ts @@ -1,6 +1,7 @@ import { LLMMessageFormat, LLMMessageFormatImplementation } from "../types"; import { openaiFormat } from "./openai"; import { langchainFormat } from "./langchain"; +import { openinferenceFormat } from "./openinference"; const FORMAT_REGISTRY: Record< LLMMessageFormat, @@ -10,6 +11,7 @@ const FORMAT_REGISTRY: Record< langchain: langchainFormat, anthropic: null, google: null, + openinference: openinferenceFormat, }; export const getFormat = ( diff --git a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/types.ts b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/types.ts index 0ff5e2cc392..bb2b7f1fa77 100644 --- a/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/types.ts +++ b/apps/opik-frontend/src/shared/PrettyLLMMessage/llmMessages/types.ts @@ -10,7 +10,17 @@ import { } from "@/shared/PrettyLLMMessage/types"; // Format types -export type LLMMessageFormat = "openai" | "langchain" | "anthropic" | "google"; +export type LLMMessageFormat = + | "openai" + | "langchain" + | "anthropic" + | "google" + | "openinference"; + +export type LLMMessagePrettifyConfig = { + fieldType?: "input" | "output"; + formatHint?: LLMMessageFormat; +}; // Detection result export interface LLMMessageFormatDetectionResult { @@ -67,13 +77,13 @@ export interface LLMMapperResult { // Format detector contract export type FormatDetector = ( data: unknown, - prettifyConfig?: { fieldType?: "input" | "output" }, + prettifyConfig?: LLMMessagePrettifyConfig, ) => boolean; // Format mapper contract export type FormatMapper = ( data: unknown, - prettifyConfig?: { fieldType?: "input" | "output" }, + prettifyConfig?: LLMMessagePrettifyConfig, ) => LLMMapperResult; // Format combiner contract diff --git a/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/MessagesTab.tsx b/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/MessagesTab.tsx index 2477b267ab3..2fffadc7437 100644 --- a/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/MessagesTab.tsx +++ b/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/MessagesTab.tsx @@ -13,6 +13,7 @@ import { mapAndCombineMessages, LLMMessageDescriptor, LLMBlockDescriptor, + LLMMessageFormat, } from "@/shared/PrettyLLMMessage/llmMessages"; import { Button } from "@/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/ui/tooltip"; @@ -21,6 +22,7 @@ import PrettyLLMMessage from "@/shared/PrettyLLMMessage"; import { useLLMMessagesExpandAll } from "@/shared/SyntaxHighlighter/hooks/useSyntaxHighlighterHooks"; import Loader from "@/shared/Loader/Loader"; import CollapsibleSection from "@/v2/pages-shared/traces/TraceDetailsPanel/CollapsibleSection"; +import { PrettyLLMMessageUsageProps } from "@/shared/PrettyLLMMessage/types"; const ESTIMATED_COLLAPSED_HEIGHT = 36; // single header row height in px const ESTIMATED_EXPANDED_HEIGHT = 200; // fallback for expanded items before measurement @@ -29,11 +31,13 @@ const VIRTUAL_OVERSCAN = 10; // extra items rendered outside viewport const TOGGLE_SUPPRESS_MS = 300; // ignore scroll adjustments after expand/collapse type MessagesTabProps = { - transformedInput: object; - transformedOutput: object; + transformedInput: unknown; + transformedOutput: unknown; media: UnifiedMediaItem[]; isLoading: boolean; scrollContainerRef?: React.RefObject; + formatHint?: LLMMessageFormat; + spanUsage?: PrettyLLMMessageUsageProps["usage"]; }; function renderBlock(descriptor: LLMBlockDescriptor, key: string) { @@ -55,10 +59,18 @@ const MessagesTab: React.FunctionComponent = ({ media, isLoading, scrollContainerRef, + formatHint, + spanUsage, }) => { const { messages: combinedMessages, usage } = useMemo( - () => mapAndCombineMessages(transformedInput, transformedOutput), - [transformedInput, transformedOutput], + () => + mapAndCombineMessages( + transformedInput, + transformedOutput, + formatHint, + spanUsage, + ), + [formatHint, spanUsage, transformedInput, transformedOutput], ); const allMessageIds = useMemo( diff --git a/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx b/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx index 048c5e27633..875093247df 100644 --- a/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx +++ b/apps/opik-frontend/src/v2/pages-shared/traces/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx @@ -36,8 +36,12 @@ import { EXPLAINER_ID, EXPLAINERS_MAP } from "@/v2/constants/explainers"; import ExplainerIcon from "@/shared/ExplainerIcon/ExplainerIcon"; import useTraceFeedbackScoreDeleteMutation from "@/api/traces/useTraceFeedbackScoreDeleteMutation"; import ConfigurableFeedbackScoreTable from "./FeedbackScoreTable/ConfigurableFeedbackScoreTable"; -import { detectLLMMessages } from "@/shared/PrettyLLMMessage/llmMessages"; +import { + detectLLMMessages, + LLMMessageFormat, +} from "@/shared/PrettyLLMMessage/llmMessages"; import { useUnifiedMedia } from "@/hooks/useUnifiedMedia"; +import { hasOpenInferenceHint } from "@/lib/openinference"; type TraceDataViewerProps = { graphData?: AgentGraphData; @@ -80,20 +84,39 @@ const TraceDataViewer: React.FunctionComponent = ({ const { media, transformedInput, transformedOutput } = useUnifiedMedia(data); + const formatHint: LLMMessageFormat | undefined = useMemo( + () => + hasOpenInferenceHint(data.metadata, transformedInput, transformedOutput) + ? "openinference" + : undefined, + [data.metadata, transformedInput, transformedOutput], + ); + // Show Messages tab when at least one field is supported and neither is invalid const canShowMessagesTab = useMemo(() => { - const input = detectLLMMessages(transformedInput, { fieldType: "input" }); - const output = detectLLMMessages(transformedOutput, { - fieldType: "output", - }); + const input = detectLLMMessages( + transformedInput, + { fieldType: "input" }, + formatHint, + ); + const output = detectLLMMessages( + transformedOutput, + { fieldType: "output" }, + formatHint, + ); const hasValid = input.supported || output.supported; + if (formatHint === "openinference") { + return ( + input.format === "openinference" || output.format === "openinference" + ); + } const hasInvalid = (!input.supported && !input.empty) || (!output.supported && !output.empty); return hasValid && !hasInvalid; - }, [transformedInput, transformedOutput]); + }, [formatHint, transformedInput, transformedOutput]); const defaultTab = canShowMessagesTab ? "messages" : "details"; @@ -313,6 +336,8 @@ const TraceDataViewer: React.FunctionComponent = ({ media={media} isLoading={isSpanInputOutputLoading} scrollContainerRef={rootScrollRef} + formatHint={formatHint} + spanUsage={data.usage} /> )} diff --git a/apps/opik-frontend/src/v2/pages-shared/traces/TraceMessages/TraceMessage.tsx b/apps/opik-frontend/src/v2/pages-shared/traces/TraceMessages/TraceMessage.tsx index 6dbd6a10998..9d769cd5ed5 100644 --- a/apps/opik-frontend/src/v2/pages-shared/traces/TraceMessages/TraceMessage.tsx +++ b/apps/opik-frontend/src/v2/pages-shared/traces/TraceMessages/TraceMessage.tsx @@ -52,7 +52,10 @@ const TraceMessage: React.FC = ({ }, [trace.input, jsonViewTheme]); const output = useMemo(() => { - const message = prettifyMessage(trace.output, { type: "output" }).message; + const message = prettifyMessage(trace.output, { + type: "output", + openInferenceInput: trace.input, + }).message; if (isObject(message)) { return ( @@ -69,7 +72,7 @@ const TraceMessage: React.FC = ({ } else { return {toString(message)}; } - }, [trace.output, jsonViewTheme]); + }, [trace.input, trace.output, jsonViewTheme]); return (
diff --git a/apps/opik-frontend/src/v2/pages/AnnotationQueuePage/ExportAnnotatedDataButton.tsx b/apps/opik-frontend/src/v2/pages/AnnotationQueuePage/ExportAnnotatedDataButton.tsx index 78cf6221df6..f4b8a7de912 100644 --- a/apps/opik-frontend/src/v2/pages/AnnotationQueuePage/ExportAnnotatedDataButton.tsx +++ b/apps/opik-frontend/src/v2/pages/AnnotationQueuePage/ExportAnnotatedDataButton.tsx @@ -123,8 +123,10 @@ const ExportAnnotatedDataButton: React.FC = ({ id: trace.id, input: prettifyMessage(trace.input, { type: "input" }) .message as JsonNode, - output: prettifyMessage(trace.output, { type: "output" }) - .message as JsonNode, + output: prettifyMessage(trace.output, { + type: "output", + openInferenceInput: trace.input, + }).message as JsonNode, metadata: trace.metadata ?? {}, }; diff --git a/apps/opik-frontend/src/v2/pages/SMEFlowPage/AnnotationView/ItemsSidebar.tsx b/apps/opik-frontend/src/v2/pages/SMEFlowPage/AnnotationView/ItemsSidebar.tsx index cdb10d31dd1..2ec3e48f841 100644 --- a/apps/opik-frontend/src/v2/pages/SMEFlowPage/AnnotationView/ItemsSidebar.tsx +++ b/apps/opik-frontend/src/v2/pages/SMEFlowPage/AnnotationView/ItemsSidebar.tsx @@ -17,11 +17,11 @@ import { useLoggedInUserNameOrOpenSourceDefaultUser } from "@/store/AppStore"; const getPreviewText = ( obj: object | undefined, type: "input" | "output", + openInferenceInput?: object, ): string => { - if (!obj) return ""; - const result = prettifyMessage(obj, { type }); + const result = prettifyMessage(obj, { type, openInferenceInput }); if (typeof result.message === "string") return result.message; - return JSON.stringify(obj).slice(0, 80); + return obj ? JSON.stringify(obj).slice(0, 80) : ""; }; const getItemPreviews = ( @@ -44,7 +44,7 @@ const getItemPreviews = ( return { name: trace.name || trace.id.slice(-12), input: getPreviewText(trace.input, "input"), - output: getPreviewText(trace.output, "output"), + output: getPreviewText(trace.output, "output", trace.input), }; };