diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index f8a5117d..bd6f7f59 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -52,6 +52,7 @@ ext.libraries = [:] dependencies { // Exposed on the public API surface (LDClientInterface), therefore `api` not `implementation`. api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}" + implementation "com.launchdarkly:launchdarkly-java-sdk-common:2.5.0" testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "junit:junit:4.13.2" diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 29be0506..40568454 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.ai.internal; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueConverter; import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java index 9fcee28f..edc81a11 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.ai.internal; import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDContextEncoder; import com.launchdarkly.sdk.server.ai.internal.mustache.Mustache; import com.launchdarkly.sdk.server.ai.internal.mustache.Template; @@ -61,7 +62,7 @@ public String interpolate(String template, Map variables, LDCont merged.putAll(variables); } // ldctx is added last so it always wins over any caller-supplied "ldctx" entry. - merged.put("ldctx", contextToMap(context)); + merged.put("ldctx", LDContextEncoder.encode(context)); return render(template, merged); } @@ -83,51 +84,4 @@ private String render(String template, Map variables) { Template compiled = templateCache.computeIfAbsent(template, compiler::compile); return compiled.execute(variables); } - - /** - * Encodes the evaluation context directly into the nested map structure exposed to templates as - * {@code ldctx}, without round-tripping through JSON serialization. A single-kind context becomes - * a map of its attributes; a multi-kind context becomes - * {@code {"kind":"multi", "key":, : {...}}} with one nested map per - * individual context. - */ - private static Map contextToMap(LDContext context) { - if (context == null || !context.isValid()) { - return new HashMap<>(); - } - if (context.isMultiple()) { - Map map = new HashMap<>(); - map.put("kind", "multi"); - map.put("key", context.getFullyQualifiedKey()); - int count = context.getIndividualContextCount(); - for (int i = 0; i < count; i++) { - LDContext individual = context.getIndividualContext(i); - if (individual != null) { - // Mirror LaunchDarkly's standard context JSON: the per-kind objects nested under a - // multi-kind context omit "kind" because it is already implied by the property key. - map.put(individual.getKind().toString(), singleContextToMap(individual, false)); - } - } - return map; - } - return singleContextToMap(context, true); - } - - private static Map singleContextToMap(LDContext context, boolean includeKind) { - Map map = new HashMap<>(); - if (includeKind) { - map.put("kind", context.getKind().toString()); - } - map.put("key", context.getKey()); - if (context.getName() != null) { - map.put("name", context.getName()); - } - map.put("anonymous", context.isAnonymous()); - // Custom attribute values can be arbitrary JSON; convert each LDValue to a plain Java value - // (depth-capped) so nested objects/arrays remain addressable from templates. - for (String attribute : context.getCustomAttributeNames()) { - map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute))); - } - return map; - } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java deleted file mode 100644 index 249b2eed..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.launchdarkly.sdk.server.ai.internal; - -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.LDValueType; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Converts an {@link LDValue} tree into a tree of plain Java values - * ({@link String}, {@link Long}, {@link Double}, {@link Boolean}, {@link List}, {@link Map}, or - * {@code null}). - *

- * This is used to expose AI Config {@code model.parameters} / {@code model.custom} and tool - * parameters to callers without leaking the {@link LDValue} type onto the public surface. - *

- * Conversion is defensive: it never throws on malformed or pathological input. Numbers are decoded - * to {@link Long} when they are mathematically integral and within the IEEE-754 exact-integer range - * ({@code |value| <= 2^53}); otherwise they are decoded to {@link Double}. Whole numbers outside - * {@code ±2^53} cannot be represented exactly and are returned as the nearest {@link Double}. - * Conversion depth is capped (see {@link #MAX_DEPTH}); values nested more deeply than the cap are - * dropped (rendered as {@code null}) to bound stack usage on adversarial input. - *

- * This class is an internal implementation detail and is not part of the supported API. - */ -public final class LDValueConverter { - /** - * Maximum nesting depth converted before deeper values are dropped. - */ - public static final int MAX_DEPTH = 100; - - /** - * Largest magnitude of a whole number that a {@code double} can represent exactly. - */ - private static final double MAX_EXACT_INTEGER = 9007199254740992.0; // 2^53 - - private LDValueConverter() { - } - - /** - * Converts an {@link LDValue} to a plain Java value. - * - * @param value the value to convert; may be {@code null} - * @return the converted value, or {@code null} if the input is {@code null} or JSON null - */ - public static Object toJavaObject(LDValue value) { - return convert(value, 0); - } - - /** - * Converts an {@link LDValue} object to an unmodifiable {@code Map}. - * - * @param value the value to convert - * @return the converted map; {@code null} if {@code value} is not a JSON object - */ - public static Map toMap(LDValue value) { - if (value == null || value.getType() != LDValueType.OBJECT) { - return null; - } - Object converted = convert(value, 0); - if (converted instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) converted; - return map; - } - return null; - } - - private static Object convert(LDValue value, int depth) { - if (value == null || value.isNull()) { - return null; - } - if (depth >= MAX_DEPTH) { - return null; - } - - LDValueType type = value.getType(); - switch (type) { - case BOOLEAN: - return value.booleanValue(); - case NUMBER: - return convertNumber(value.doubleValue()); - case STRING: - return value.stringValue(); - case ARRAY: { - List list = new ArrayList<>(value.size()); - for (LDValue element : value.values()) { - list.add(convert(element, depth + 1)); - } - return Collections.unmodifiableList(list); - } - case OBJECT: { - // LinkedHashMap to preserve field order for deterministic output. - Map map = new LinkedHashMap<>(); - for (String key : value.keys()) { - map.put(key, convert(value.get(key), depth + 1)); - } - return Collections.unmodifiableMap(map); - } - case NULL: - default: - return null; - } - } - - private static Object convertNumber(double d) { - if (!Double.isNaN(d) && !Double.isInfinite(d) - && d == Math.rint(d) && Math.abs(d) <= MAX_EXACT_INTEGER) { - return (long) d; - } - return d; - } -} diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java deleted file mode 100644 index 17872c97..00000000 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.launchdarkly.sdk.server.ai.internal; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.instanceOf; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; - -import com.launchdarkly.sdk.LDValue; - -import java.util.List; -import java.util.Map; - -import org.junit.Test; - -@SuppressWarnings("javadoc") -public class LDValueConverterTest { - @Test - public void nullAndJsonNullConvertToNull() { - assertThat(LDValueConverter.toJavaObject(null), is(nullValue())); - assertThat(LDValueConverter.toJavaObject(LDValue.ofNull()), is(nullValue())); - } - - @Test - public void integralNumberBecomesLong() { - Object converted = LDValueConverter.toJavaObject(LDValue.of(100)); - assertThat(converted, instanceOf(Long.class)); - assertThat(converted, is((Object) 100L)); - } - - @Test - public void fractionalNumberBecomesDouble() { - Object converted = LDValueConverter.toJavaObject(LDValue.of(0.75)); - assertThat(converted, instanceOf(Double.class)); - assertThat(converted, is((Object) 0.75)); - } - - @Test - public void stringAndBooleanConvertDirectly() { - assertThat(LDValueConverter.toJavaObject(LDValue.of("hi")), is((Object) "hi")); - assertThat(LDValueConverter.toJavaObject(LDValue.of(true)), is((Object) Boolean.TRUE)); - } - - @Test - public void nestedObjectAndArrayConvert() { - LDValue value = LDValue.parse("{\"a\":1,\"b\":[\"x\",2],\"c\":{\"d\":true}}"); - Map map = LDValueConverter.toMap(value); - assertThat(map.get("a"), is((Object) 1L)); - assertThat(((List) map.get("b")).get(0), is((Object) "x")); - assertThat(((List) map.get("b")).get(1), is((Object) 2L)); - assertThat(((Map) map.get("c")).get("d"), is((Object) Boolean.TRUE)); - } - - @Test - public void toMapReturnsNullForNonObject() { - assertThat(LDValueConverter.toMap(LDValue.of("not-an-object")), is(nullValue())); - assertThat(LDValueConverter.toMap(LDValue.parse("[1,2,3]")), is(nullValue())); - } - - @Test - public void deeplyNestedInputDoesNotOverflowAndIsCapped() { - int depth = LDValueConverter.MAX_DEPTH + 50; - StringBuilder json = new StringBuilder(); - for (int i = 0; i < depth; i++) { - json.append('['); - } - for (int i = 0; i < depth; i++) { - json.append(']'); - } - // Should neither throw nor StackOverflow; the top level is still a List. - Object converted = LDValueConverter.toJavaObject(LDValue.parse(json.toString())); - assertThat(converted, instanceOf(List.class)); - } -}