diff --git a/manifests/java.yml b/manifests/java.yml index d3424d776d3..6d0b0a02172 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -33,16 +33,46 @@ manifest: - weblog_declaration: "*": irrelevant (just one weblog is enough to test the SDK) "spring-boot": v1.57.0 - tests/ai_guard/test_ai_guard_sdk.py::Test_NoRedaction: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactedMessagesInSDKResponse: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_Redaction: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionDisabled: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionDisabledTelemetry: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionFailSafe: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionInSDKResponse: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionMultiTurnContext: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionOnBlock: missing_feature - tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionSkipsStructuralFields: missing_feature + tests/ai_guard/test_ai_guard_sdk.py::Test_NoRedaction: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactedMessagesInSDKResponse: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_Redaction: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionDisabled: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionDisabledTelemetry: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionFailSafe: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionInSDKResponse: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionMultiTurnContext: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionOnBlock: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT + tests/ai_guard/test_ai_guard_sdk.py::Test_RedactionSkipsStructuralFields: + - weblog_declaration: + "*": irrelevant (just one weblog is enough to test the SDK) + "spring-boot": v1.66.0-SNAPSHOT tests/ai_guard/test_ai_guard_sdk.py::Test_RootSpanUserKeep: - weblog_declaration: "*": irrelevant (just one weblog is enough to test the SDK) diff --git a/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/ai_guard/AIGuardController.java b/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/ai_guard/AIGuardController.java index 4b9b0bac3b1..4329310f522 100644 --- a/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/ai_guard/AIGuardController.java +++ b/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/ai_guard/AIGuardController.java @@ -2,7 +2,10 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; import datadog.trace.api.aiguard.AIGuard; import datadog.trace.api.aiguard.AIGuard.Evaluation; import datadog.trace.api.interceptor.MutableSpan; @@ -18,11 +21,9 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; -import java.lang.reflect.Method; +import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; @@ -35,7 +36,8 @@ public static class JacksonConfig { public Jackson2ObjectMapperBuilderCustomizer mixInCustomizer() { return builder -> builder .mixIn(AIGuard.AIGuardAbortError.class, AIGuardAbortErrorMixIn.class) - .mixIn(AIGuard.Evaluation.class, AIGuardEvaluationMixIn.class); + .mixIn(AIGuard.Evaluation.class, AIGuardEvaluationMixIn.class) + .serializerByType(AIGuard.Message.class, new MessageSerializer()); } } @@ -131,15 +133,13 @@ public void setName(String name) { } public AIGuard.Message toAIGuard() { - if (toolCallId != null) { - String contentStr = content != null && content.isTextual() ? content.asText() : null; - return AIGuard.Message.tool(toolCallId, contentStr); - } - if (toolCalls != null && !toolCalls.isEmpty()) { - return AIGuard.Message.assistant( - toolCalls.stream().map(ToolCall::toAIGuard).toArray(AIGuard.ToolCall[]::new)); - } - // Handle content parts vs string content + // Every field is carried over independently: an assistant message can hold content + // *and* tool calls at the same time, and dropping either half would change the + // conversation the tracer sends to the AI Guard service. + List calls = toolCalls == null || toolCalls.isEmpty() + ? null + : toolCalls.stream().map(ToolCall::toAIGuard).collect(Collectors.toList()); + if (content != null && content.isArray()) { // Content parts format List parts = new ArrayList<>(); @@ -152,12 +152,11 @@ public AIGuard.Message toAIGuard() { parts.add(AIGuard.ContentPart.imageUrl(url)); } } - return AIGuard.Message.message(role, parts); - } else { - // String content format - String contentStr = content != null && content.isTextual() ? content.asText() : null; - return AIGuard.Message.message(role, contentStr); + return new AIGuard.Message(role, parts, calls, toolCallId); } + // String content format + String contentStr = content != null && content.isTextual() ? content.asText() : null; + return new AIGuard.Message(role, contentStr, calls, toolCallId); } } @@ -234,6 +233,89 @@ public static abstract class AIGuardEvaluationMixIn { @JsonProperty("tag_probs") abstract Object getTagProbabilities(); + + @JsonProperty("redaction_replacements") + abstract Object getRedactionReplacements(); + } + + /** + * Writes {@link AIGuard.Message} back in the very shape the request carried it. + * + *

Bean serialization would emit the SDK's field names and a null for every field the + * message does not use, which no other tracer's weblog does and which makes the response + * impossible to compare against the messages that were sent. Content parts live under + * {@code content} as an array, exactly like the OpenAI wire format the SDK models. + */ + public static class MessageSerializer extends JsonSerializer { + + @Override + public void serialize(final AIGuard.Message message, + final JsonGenerator gen, + final SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + if (message.getRole() != null) { + gen.writeStringField("role", message.getRole()); + } + if (message.getContentParts() != null) { + gen.writeFieldName("content"); + gen.writeStartArray(); + for (final AIGuard.ContentPart part : message.getContentParts()) { + writeContentPart(part, gen); + } + gen.writeEndArray(); + } else if (message.getContent() != null) { + // Written on nullness, not emptiness: "" is the redaction remove strategy. + gen.writeStringField("content", message.getContent()); + } + if (message.getToolCalls() != null) { + gen.writeFieldName("tool_calls"); + gen.writeStartArray(); + for (final AIGuard.ToolCall toolCall : message.getToolCalls()) { + writeToolCall(toolCall, gen); + } + gen.writeEndArray(); + } + if (message.getToolCallId() != null) { + gen.writeStringField("tool_call_id", message.getToolCallId()); + } + gen.writeEndObject(); + } + + private static void writeContentPart(final AIGuard.ContentPart part, + final JsonGenerator gen) throws IOException { + gen.writeStartObject(); + gen.writeStringField("type", part.getType().toString()); + if (part.getType() == AIGuard.ContentPart.Type.TEXT) { + gen.writeStringField("text", part.getText()); + } else if (part.getType() == AIGuard.ContentPart.Type.IMAGE_URL) { + gen.writeFieldName("image_url"); + gen.writeStartObject(); + gen.writeStringField("url", part.getImageUrl().getUrl()); + gen.writeEndObject(); + } + gen.writeEndObject(); + } + + private static void writeToolCall(final AIGuard.ToolCall toolCall, + final JsonGenerator gen) throws IOException { + gen.writeStartObject(); + if (toolCall.getId() != null) { + gen.writeStringField("id", toolCall.getId()); + } + final AIGuard.ToolCall.Function function = toolCall.getFunction(); + if (function != null) { + gen.writeFieldName("function"); + gen.writeStartObject(); + if (function.getName() != null) { + gen.writeStringField("name", function.getName()); + } + if (function.getArguments() != null) { + gen.writeStringField("arguments", function.getArguments()); + } + gen.writeEndObject(); + } + gen.writeEndObject(); + } } }