diff --git a/instrumentation-api-incubator/build.gradle.kts b/instrumentation-api-incubator/build.gradle.kts index c25f6aea378d..c9cf70932ee8 100644 --- a/instrumentation-api-incubator/build.gradle.kts +++ b/instrumentation-api-incubator/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { api("io.opentelemetry:opentelemetry-api-incubator") compileOnly("com.google.auto.value:auto-value-annotations") + compileOnly("com.fasterxml.jackson.core:jackson-databind") annotationProcessor("com.google.auto.value:auto-value") testImplementation(project(":testing-common")) diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/GenericPart.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/GenericPart.java new file mode 100644 index 000000000000..1255dd5aac67 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/GenericPart.java @@ -0,0 +1,33 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.google.auto.value.AutoValue; +import java.util.Map; + +/** + * Represents an arbitrary message part with any type and properties. This allows for extensibility + * with custom message part types. + */ +@AutoValue +@JsonClassDescription("Generic part") +public abstract class GenericPart implements MessagePart { + + @JsonProperty(required = true, value = "type") + @JsonPropertyDescription("The type of the content captured in this part") + public abstract String getType(); + + public static GenericPart create(String type) { + return new AutoValue_GenericPart(type); + } + + public static GenericPart create(String type, Map additionalProperties) { + return new AutoValue_GenericPart(type); + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/InputMessage.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/InputMessage.java new file mode 100644 index 000000000000..4ed5c946f598 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/InputMessage.java @@ -0,0 +1,34 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.google.auto.value.AutoValue; +import java.util.List; + +/** Represents an input message sent to the model. */ +@AutoValue +@JsonClassDescription("Input message") +public abstract class InputMessage { + + @JsonProperty(required = true, value = "role") + @JsonPropertyDescription("Role of the entity that created the message") + public abstract String getRole(); + + @JsonProperty(required = true, value = "parts") + @JsonPropertyDescription("List of message parts that make up the message content") + public abstract List getParts(); + + public static InputMessage create(String role, List parts) { + return new AutoValue_InputMessage(role, parts); + } + + public static InputMessage create(Role role, List parts) { + return new AutoValue_InputMessage(role.getValue(), parts); + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/InputMessages.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/InputMessages.java new file mode 100644 index 000000000000..65391f1820af --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/InputMessages.java @@ -0,0 +1,42 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auto.value.AutoValue; +import java.util.ArrayList; +import java.util.List; + +/** Represents a collection of input messages sent to the model. */ +@AutoValue +public abstract class InputMessages { + + public abstract List getMessages(); + + public static InputMessages create() { + return new AutoValue_InputMessages(new ArrayList<>()); + } + + public static InputMessages create(List messages) { + return new AutoValue_InputMessages(new ArrayList<>(messages)); + } + + public InputMessages append(InputMessage inputMessage) { + List messages = getMessages(); + messages.add(inputMessage); + return this; + } + + public String toJsonString() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize InputMessages to JSON", e); + } + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/MessagePart.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/MessagePart.java new file mode 100644 index 000000000000..07c325ce72e8 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/MessagePart.java @@ -0,0 +1,17 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +/** Interface for all message parts. */ +public interface MessagePart { + + /** + * Get the type of this message part. + * + * @return the type string + */ + String getType(); +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/OutputMessage.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/OutputMessage.java new file mode 100644 index 000000000000..bd16a23b2d8f --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/OutputMessage.java @@ -0,0 +1,37 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.google.auto.value.AutoValue; +import java.util.List; + +@AutoValue +@JsonClassDescription("Output message") +public abstract class OutputMessage { + + @JsonProperty(required = true, value = "role") + @JsonPropertyDescription("Role of response") + public abstract String getRole(); + + @JsonProperty(required = true, value = "parts") + @JsonPropertyDescription("List of message parts that make up the message content") + public abstract List getParts(); + + @JsonProperty(required = true, value = "finish_reason") + @JsonPropertyDescription("Reason for finishing the generation") + public abstract String getFinishReason(); + + public static OutputMessage create(String role, List parts, String finishReason) { + return new AutoValue_OutputMessage(role, parts, finishReason); + } + + public static OutputMessage create(Role role, List parts, String finishReason) { + return new AutoValue_OutputMessage(role.getValue(), parts, finishReason); + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/OutputMessages.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/OutputMessages.java new file mode 100644 index 000000000000..7de8e325c22b --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/OutputMessages.java @@ -0,0 +1,112 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auto.value.AutoValue; +import java.util.ArrayList; +import java.util.List; + +/** Represents a collection of output messages from the model. */ +@AutoValue +public abstract class OutputMessages { + + public abstract List getMessages(); + + public static OutputMessages create() { + return new AutoValue_OutputMessages(new ArrayList<>()); + } + + public static OutputMessages create(List messages) { + return new AutoValue_OutputMessages(new ArrayList<>(messages)); + } + + public OutputMessages append(OutputMessage outputMessage) { + List currentMessages = new ArrayList<>(getMessages()); + currentMessages.add(outputMessage); + return new AutoValue_OutputMessages(currentMessages); + } + + public String toJsonString() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize OutputMessages to JSON", e); + } + } + + /** + * Merges a chunk OutputMessage into the existing messages at the specified index. This method is + * used for streaming responses where content is received in chunks. + * + * @param index the index of the message to merge into + * @param chunkMessage the chunk message to append + * @return a new OutputMessages instance with the merged content + */ + public OutputMessages merge(int index, OutputMessage chunkMessage) { + List currentMessages = new ArrayList<>(getMessages()); + + if (index < 0 || index >= currentMessages.size()) { + throw new IllegalArgumentException( + "Index " + + index + + " is out of bounds for messages list of size " + + currentMessages.size()); + } + + OutputMessage existingMessage = currentMessages.get(index); + + // Merge the parts by appending text content from chunk to existing message + List mergedParts = new ArrayList<>(existingMessage.getParts()); + + // If the chunk message has text parts, append their content to the first text part of existing + // message + for (MessagePart chunkPart : chunkMessage.getParts()) { + if (chunkPart instanceof TextPart) { + TextPart chunkTextPart = (TextPart) chunkPart; + + // Find the first text part in existing message to append to + boolean appended = false; + for (int i = 0; i < mergedParts.size(); i++) { + MessagePart existingPart = mergedParts.get(i); + if (existingPart instanceof TextPart) { + TextPart existingTextPart = (TextPart) existingPart; + // Create a new TextPart with combined content + TextPart mergedTextPart = + TextPart.create(existingTextPart.getContent() + chunkTextPart.getContent()); + mergedParts.set(i, mergedTextPart); + appended = true; + break; + } + } + + // If no existing text part found, add the chunk as a new part + if (!appended) { + mergedParts.add(chunkTextPart); + } + } else { + // For non-text parts, add them as new parts + mergedParts.add(chunkPart); + } + } + + // Create new OutputMessage with merged parts, using the chunk's finish reason if available + String finalFinishReason = + chunkMessage.getFinishReason() != null + ? chunkMessage.getFinishReason() + : existingMessage.getFinishReason(); + + OutputMessage mergedMessage = + OutputMessage.create(existingMessage.getRole(), mergedParts, finalFinishReason); + + // Replace the message at the specified index + currentMessages.set(index, mergedMessage); + + return new AutoValue_OutputMessages(currentMessages); + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/Role.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/Role.java new file mode 100644 index 000000000000..e764fee197a6 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/Role.java @@ -0,0 +1,24 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +public enum Role { + SYSTEM("system"), + USER("user"), + ASSISTANT("assistant"), + TOOL("tool"), + DEVELOPER("developer"); + + private final String value; + + public String getValue() { + return value; + } + + Role(String value) { + this.value = value; + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/SystemInstructions.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/SystemInstructions.java new file mode 100644 index 000000000000..6b4a655f9f95 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/SystemInstructions.java @@ -0,0 +1,35 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auto.value.AutoValue; +import java.util.List; + +/** Represents the list of system instructions sent to the model. */ +@AutoValue +@JsonClassDescription("System instructions") +public abstract class SystemInstructions { + + @JsonPropertyDescription("List of message parts that make up the system instructions") + public abstract List getParts(); + + public static SystemInstructions create(List parts) { + return new AutoValue_SystemInstructions(parts); + } + + public String toJsonString() { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize SystemInstructions to JSON", e); + } + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/TextPart.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/TextPart.java new file mode 100644 index 000000000000..a899fbf10a5a --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/TextPart.java @@ -0,0 +1,29 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.google.auto.value.AutoValue; + +/** Represents text content sent to or received from the model. */ +@AutoValue +@JsonClassDescription("Text part") +public abstract class TextPart implements MessagePart { + + @JsonProperty(required = true, value = "type") + @JsonPropertyDescription("The type of the content captured in this part") + public abstract String getType(); + + @JsonProperty(required = true, value = "content") + @JsonPropertyDescription("Text content sent to or received from the model") + public abstract String getContent(); + + public static TextPart create(String content) { + return new AutoValue_TextPart("text", content); + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/ToolCallRequestPart.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/ToolCallRequestPart.java new file mode 100644 index 000000000000..09b21fdc3b98 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/ToolCallRequestPart.java @@ -0,0 +1,44 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; + +/** Represents a tool call requested by the model. */ +@AutoValue +@JsonClassDescription("Tool call request part") +public abstract class ToolCallRequestPart implements MessagePart { + + @JsonProperty(required = true, value = "type") + @JsonPropertyDescription("The type of the content captured in this part") + public abstract String getType(); + + @JsonProperty(value = "id") + @JsonPropertyDescription("Unique identifier for the tool call") + @Nullable + public abstract String getId(); + + @JsonProperty(required = true, value = "name") + @JsonPropertyDescription("Name of the tool") + public abstract String getName(); + + @JsonProperty(value = "arguments") + @JsonPropertyDescription("Arguments for the tool call") + @Nullable + public abstract Object getArguments(); + + public static ToolCallRequestPart create(String name) { + return new AutoValue_ToolCallRequestPart("tool_call", null, name, null); + } + + public static ToolCallRequestPart create(String id, String name, Object arguments) { + return new AutoValue_ToolCallRequestPart("tool_call", id, name, arguments); + } +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/ToolCallResponsePart.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/ToolCallResponsePart.java new file mode 100644 index 000000000000..91acad625eec --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/messages/ToolCallResponsePart.java @@ -0,0 +1,39 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; + +/** Represents a tool call result sent to the model or a built-in tool call outcome and details. */ +@AutoValue +@JsonClassDescription("Tool call response part") +public abstract class ToolCallResponsePart implements MessagePart { + + @JsonProperty(required = true, value = "type") + @JsonPropertyDescription("The type of the content captured in this part") + public abstract String getType(); + + @JsonProperty(value = "id") + @JsonPropertyDescription("Unique tool call identifier") + @Nullable + public abstract String getId(); + + @JsonProperty(required = true, value = "response") + @JsonPropertyDescription("Tool call response") + public abstract Object getResponse(); + + public static ToolCallResponsePart create(Object response) { + return new AutoValue_ToolCallResponsePart("tool_call_response", null, response); + } + + public static ToolCallResponsePart create(String id, Object response) { + return new AutoValue_ToolCallResponsePart("tool_call_response", id, response); + } +} diff --git a/instrumentation/openai/openai-java-1.1/library/src/main/java/io/opentelemetry/instrumentation/openai/v1_1/ChatCompletionMessagesConverter.java b/instrumentation/openai/openai-java-1.1/library/src/main/java/io/opentelemetry/instrumentation/openai/v1_1/ChatCompletionMessagesConverter.java new file mode 100644 index 000000000000..c01ad781fb2a --- /dev/null +++ b/instrumentation/openai/openai-java-1.1/library/src/main/java/io/opentelemetry/instrumentation/openai/v1_1/ChatCompletionMessagesConverter.java @@ -0,0 +1,424 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.openai.v1_1; + +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam; +import com.openai.models.chat.completions.ChatCompletionContentPartText; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionDeveloperMessageParam; +import com.openai.models.chat.completions.ChatCompletionMessage; +import com.openai.models.chat.completions.ChatCompletionMessageParam; +import com.openai.models.chat.completions.ChatCompletionMessageToolCall; +import com.openai.models.chat.completions.ChatCompletionSystemMessageParam; +import com.openai.models.chat.completions.ChatCompletionToolMessageParam; +import com.openai.models.chat.completions.ChatCompletionUserMessageParam; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.InputMessage; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.InputMessages; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.MessagePart; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.OutputMessage; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.OutputMessages; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.Role; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.TextPart; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.ToolCallRequestPart; +import io.opentelemetry.instrumentation.api.incubator.semconv.genai.messages.ToolCallResponsePart; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import javax.annotation.Nullable; + +// as a field of Attributes Extractor +// replace the 'ChatCompletionEventsHelper' +public final class ChatCompletionMessagesConverter { + + private final boolean captureMessageContent; + + public ChatCompletionMessagesConverter(boolean captureMessageContent) { + this.captureMessageContent = captureMessageContent; + } + + public InputMessages createInputMessages(ChatCompletionCreateParams request) { + if (!captureMessageContent) { + return null; + } + + InputMessages inputMessages = InputMessages.create(); + for (ChatCompletionMessageParam msg : request.messages()) { + + if (msg.isSystem()) { + inputMessages.append( + InputMessage.create(Role.SYSTEM, contentToMessageParts(msg.asSystem().content()))); + } else if (msg.isDeveloper()) { + inputMessages.append( + InputMessage.create( + Role.DEVELOPER, contentToMessageParts(msg.asDeveloper().content()))); + } else if (msg.isUser()) { + inputMessages.append( + InputMessage.create(Role.USER, contentToMessageParts(msg.asUser().content()))); + } else if (msg.isAssistant()) { + ChatCompletionAssistantMessageParam assistantMsg = msg.asAssistant(); + List messageParts = new ArrayList<>(); + assistantMsg + .content() + .ifPresent(content -> messageParts.addAll(contentToMessageParts(content))); + + assistantMsg + .toolCalls() + .ifPresent( + toolCalls -> { + messageParts.addAll( + toolCalls.stream() + .map(ChatCompletionMessagesConverter::toolCallToMessagePart) + .filter(Objects::nonNull) + .collect(Collectors.toList())); + }); + inputMessages.append(InputMessage.create(Role.ASSISTANT, messageParts)); + } else if (msg.isTool()) { + ChatCompletionToolMessageParam toolMsg = msg.asTool(); + inputMessages.append( + InputMessage.create( + Role.TOOL, contentToMessageParts(toolMsg.toolCallId(), toolMsg.content()))); + } else { + continue; + } + } + return inputMessages; + } + + public OutputMessages createOutputMessages(ChatCompletion completion) { + if (!captureMessageContent) { + return null; + } + + OutputMessages outputMessages = OutputMessages.create(); + for (ChatCompletion.Choice choice : completion.choices()) { + ChatCompletionMessage choiceMsg = choice.message(); + List messageParts = new ArrayList<>(); + + choiceMsg.content().ifPresent(content -> messageParts.add(TextPart.create(content))); + choiceMsg + .toolCalls() + .ifPresent( + toolCalls -> { + messageParts.addAll( + toolCalls.stream() + .map(ChatCompletionMessagesConverter::toolCallToMessagePart) + .filter(Objects::nonNull) + .collect(Collectors.toList())); + }); + + outputMessages.append( + OutputMessage.create(Role.ASSISTANT, messageParts, choice.finishReason().asString())); + } + return outputMessages; + } + + private static List contentToMessageParts( + String toolCallId, ChatCompletionToolMessageParam.Content content) { + if (content.isText()) { + return Collections.singletonList(ToolCallResponsePart.create(toolCallId, content.asText())); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .map(ChatCompletionContentPartText::text) + .map(response -> ToolCallResponsePart.create(toolCallId, response)) + .collect(Collectors.toList()); + } else { + return Collections.singletonList(ToolCallResponsePart.create("")); + } + } + + private static List contentToMessageParts( + ChatCompletionAssistantMessageParam.Content content) { + if (content.isText()) { + return Collections.singletonList(TextPart.create(content.asText())); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .map( + part -> { + if (part.isText()) { + return part.asText().text(); + } + if (part.isRefusal()) { + return part.asRefusal().refusal(); + } + return null; + }) + .filter(Objects::nonNull) + .map(TextPart::create) + .collect(Collectors.toList()); + } else { + return Collections.singletonList(TextPart.create("")); + } + } + + private static List contentToMessageParts( + ChatCompletionSystemMessageParam.Content content) { + if (content.isText()) { + return Collections.singletonList(TextPart.create(content.asText())); + } else if (content.isArrayOfContentParts()) { + return joinContentParts(content.asArrayOfContentParts()); + } else { + return Collections.singletonList(TextPart.create("")); + } + } + + private static List contentToMessageParts( + ChatCompletionDeveloperMessageParam.Content content) { + if (content.isText()) { + return Collections.singletonList(TextPart.create(content.asText())); + } else if (content.isArrayOfContentParts()) { + return joinContentParts(content.asArrayOfContentParts()); + } else { + return Collections.singletonList(TextPart.create("")); + } + } + + private static List contentToMessageParts( + ChatCompletionUserMessageParam.Content content) { + if (content.isText()) { + return Collections.singletonList(TextPart.create(content.asText())); + } else if (content.isArrayOfContentParts()) { + return content.asArrayOfContentParts().stream() + .map(part -> part.isText() ? part.asText().text() : null) + .filter(Objects::nonNull) + .map(TextPart::create) + .collect(Collectors.toList()); + } else { + return Collections.singletonList(TextPart.create("")); + } + } + + private static List joinContentParts( + List contentParts) { + return contentParts.stream() + .map(ChatCompletionContentPartText::text) + .map(TextPart::create) + .collect(Collectors.toList()); + } + + private static MessagePart toolCallToMessagePart(ChatCompletionMessageToolCall call) { + FunctionAccess functionAccess = getFunctionAccess(call); + if (functionAccess != null) { + return ToolCallRequestPart.create( + functionAccess.id(), functionAccess.name(), functionAccess.arguments()); + } + return null; + } + + @Nullable + private static FunctionAccess getFunctionAccess(ChatCompletionMessageToolCall call) { + if (V1FunctionAccess.isAvailable()) { + return V1FunctionAccess.create(call); + } + if (V3FunctionAccess.isAvailable()) { + return V3FunctionAccess.create(call); + } + + return null; + } + + private interface FunctionAccess { + String id(); + + String name(); + + String arguments(); + } + + private static String invokeStringHandle(@Nullable MethodHandle methodHandle, Object object) { + if (methodHandle == null) { + return ""; + } + + try { + return (String) methodHandle.invoke(object); + } catch (Throwable ignore) { + return ""; + } + } + + private static class V1FunctionAccess implements FunctionAccess { + @Nullable private static final MethodHandle idHandle; + @Nullable private static final MethodHandle functionHandle; + @Nullable private static final MethodHandle nameHandle; + @Nullable private static final MethodHandle argumentsHandle; + + static { + MethodHandle id; + MethodHandle function; + MethodHandle name; + MethodHandle arguments; + + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + id = + lookup.findVirtual( + ChatCompletionMessageToolCall.class, "id", MethodType.methodType(String.class)); + Class functionClass = + Class.forName( + "com.openai.models.chat.completions.ChatCompletionMessageToolCall$Function"); + function = + lookup.findVirtual( + ChatCompletionMessageToolCall.class, + "function", + MethodType.methodType(functionClass)); + name = lookup.findVirtual(functionClass, "name", MethodType.methodType(String.class)); + arguments = + lookup.findVirtual(functionClass, "arguments", MethodType.methodType(String.class)); + } catch (Exception exception) { + id = null; + function = null; + name = null; + arguments = null; + } + idHandle = id; + functionHandle = function; + nameHandle = name; + argumentsHandle = arguments; + } + + private final ChatCompletionMessageToolCall toolCall; + private final Object function; + + V1FunctionAccess(ChatCompletionMessageToolCall toolCall, Object function) { + this.toolCall = toolCall; + this.function = function; + } + + @Nullable + static FunctionAccess create(ChatCompletionMessageToolCall toolCall) { + if (functionHandle == null) { + return null; + } + + try { + return new V1FunctionAccess(toolCall, functionHandle.invoke(toolCall)); + } catch (Throwable ignore) { + return null; + } + } + + static boolean isAvailable() { + return idHandle != null; + } + + @Override + public String id() { + return invokeStringHandle(idHandle, toolCall); + } + + @Override + public String name() { + return invokeStringHandle(nameHandle, function); + } + + @Override + public String arguments() { + return invokeStringHandle(argumentsHandle, function); + } + } + + static class V3FunctionAccess implements FunctionAccess { + @Nullable private static final MethodHandle functionToolCallHandle; + @Nullable private static final MethodHandle idHandle; + @Nullable private static final MethodHandle functionHandle; + @Nullable private static final MethodHandle nameHandle; + @Nullable private static final MethodHandle argumentsHandle; + + static { + MethodHandle functionToolCall; + MethodHandle id; + MethodHandle function; + MethodHandle name; + MethodHandle arguments; + + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + functionToolCall = + lookup.findVirtual( + ChatCompletionMessageToolCall.class, + "function", + MethodType.methodType(Optional.class)); + Class functionToolCallClass = + Class.forName( + "com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall"); + id = lookup.findVirtual(functionToolCallClass, "id", MethodType.methodType(String.class)); + Class functionClass = + Class.forName( + "com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall$Function"); + function = + lookup.findVirtual( + functionToolCallClass, "function", MethodType.methodType(functionClass)); + name = lookup.findVirtual(functionClass, "name", MethodType.methodType(String.class)); + arguments = + lookup.findVirtual(functionClass, "arguments", MethodType.methodType(String.class)); + } catch (Exception exception) { + functionToolCall = null; + id = null; + function = null; + name = null; + arguments = null; + } + functionToolCallHandle = functionToolCall; + idHandle = id; + functionHandle = function; + nameHandle = name; + argumentsHandle = arguments; + } + + private final Object functionToolCall; + private final Object function; + + V3FunctionAccess(Object functionToolCall, Object function) { + this.functionToolCall = functionToolCall; + this.function = function; + } + + @Nullable + @SuppressWarnings("unchecked") + static FunctionAccess create(ChatCompletionMessageToolCall toolCall) { + if (functionToolCallHandle == null || functionHandle == null) { + return null; + } + + try { + Optional optional = (Optional) functionToolCallHandle.invoke(toolCall); + if (!optional.isPresent()) { + return null; + } + Object functionToolCall = optional.get(); + return new V3FunctionAccess(functionToolCall, functionHandle.invoke(functionToolCall)); + } catch (Throwable ignore) { + return null; + } + } + + static boolean isAvailable() { + return idHandle != null; + } + + @Override + public String id() { + return invokeStringHandle(idHandle, functionToolCall); + } + + @Override + public String name() { + return invokeStringHandle(nameHandle, function); + } + + @Override + public String arguments() { + return invokeStringHandle(argumentsHandle, function); + } + } +}