From b94e0a195b892ad10018b887f8fa7365d120d48b Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 8 Jun 2026 12:11:51 -0400 Subject: [PATCH 01/12] feat: vendor Mustache templating engine & add Interpolator (AIC-2695) Vendor the com.samskivert:jmustache:1.15 source into the relocated internal package com.launchdarkly.sdk.server.ai.internal.mustache instead of linking the external artifact, per the SDK team's supply-chain guidance. The library is now compiled from source and ships inside this jar with no third-party runtime dependency, and compiles to Java 8 bytecode against our target (avoiding the class-version mismatch the external jar hit on JDK 8). Also (re)adds the internal Interpolator, which renders AI Config message and instruction templates using the cross-SDK policy (no HTML escaping, missing/null render empty, reserved ldctx wins) and caches compiled templates. - Vendored source kept byte-for-byte from upstream aside from a provenance banner and the relocated package declaration. - THIRD-PARTY-NOTICES.txt records the upstream BSD 3-Clause license. - Vendored package excluded from checkstyle; internal package already excluded from the published javadoc/sources jars. Co-authored-by: Cursor --- lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt | 43 + lib/sdk/server-ai/build.gradle | 17 +- .../sdk/server/ai/internal/Interpolator.java | 97 ++ .../ai/internal/mustache/BasicCollector.java | 180 +++ .../internal/mustache/DefaultCollector.java | 149 +++ .../server/ai/internal/mustache/Escapers.java | 47 + .../server/ai/internal/mustache/Mustache.java | 1048 +++++++++++++++++ .../internal/mustache/MustacheException.java | 50 + .../mustache/MustacheParseException.java | 24 + .../server/ai/internal/mustache/Template.java | 441 +++++++ .../server/ai/internal/InterpolatorTest.java | 97 ++ 11 files changed, 2189 insertions(+), 4 deletions(-) create mode 100644 lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java diff --git a/lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt b/lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000..fbf880e2 --- /dev/null +++ b/lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,43 @@ +This product includes vendored third-party source code. The relevant licenses +and copyright notices are reproduced below. + +================================================================================ +JMustache (jmustache) +-------------------------------------------------------------------------------- +Vendored from: com.samskivert:jmustache:1.15 +Upstream: https://github.com/samskivert/jmustache +Location: src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/ + +The JMustache source has been relocated into the internal, non-public package +com.launchdarkly.sdk.server.ai.internal.mustache and is compiled from source as +part of this library. Aside from the relocated package declaration and a short +provenance banner at the top of each file, the source is unmodified from the +upstream 1.15 release. + +License: The (New) BSD License (BSD 3-Clause) + +Copyright (c) 2010, Michael Bayne +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * The name Michael Bayne may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +================================================================================ diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index 1a3acfec..7905dfe2 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -46,10 +46,12 @@ ext.versions = [ // appears in this library's public signature, so it is exposed as an `api` dependency. "sdk": "7.14.0" // NOTE: the Mustache templating engine (for AI Config message/instruction interpolation) is - // intentionally not declared here. Per the SDK team's supply-chain guidance we will not link the - // external com.samskivert:jmustache artifact; the library will be vendored (source copied into an - // internal, relocated package) along with the Interpolator in AIC-2695, which must land before the - // v1.0 release (AIC-2666). + // intentionally NOT declared as an external dependency. Per the SDK team's supply-chain guidance + // we do not link the external com.samskivert:jmustache artifact; its source has been vendored + // (copied into the relocated internal package com.launchdarkly.sdk.server.ai.internal.mustache) + // so it is compiled from source and ships inside this jar with no third-party runtime dependency. + // See THIRD-PARTY-NOTICES.txt for the upstream license (BSD 3-Clause, Copyright (c) 2010 Michael + // Bayne) and src/.../internal/mustache for the vendored source (AIC-2695). ] ext.libraries = [:] @@ -111,6 +113,13 @@ checkstyle { configFile = file("${project.rootDir}/checkstyle.xml") } +// The vendored Mustache source (com.launchdarkly.sdk.server.ai.internal.mustache) is third-party +// code kept as close to upstream as possible; do not subject it to our checkstyle conventions. +def vendoredPackageGlob = "**/com/launchdarkly/sdk/server/ai/internal/mustache/**" +tasks.named('checkstyleMain') { + exclude vendoredPackageGlob +} + idea { module { downloadJavadoc = true 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 new file mode 100644 index 00000000..93477cd6 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java @@ -0,0 +1,97 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.ai.internal.mustache.Mustache; +import com.launchdarkly.sdk.server.ai.internal.mustache.Template; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Renders AI Config message and instruction templates using Mustache, following the cross-SDK + * interpolation policy shared with the JS and Python SDKs: + * + *

+ * Compiled templates are cached, keyed by template text. The class is thread-safe: the Mustache + * compiler is immutable once configured, compiled {@link Template}s are safe for concurrent + * execution, and the cache is a {@link ConcurrentHashMap}. + *

+ * This class is an internal implementation detail and is not part of the supported API. + */ +public final class Interpolator { + private final Mustache.Compiler compiler; + private final ConcurrentHashMap templateCache = new ConcurrentHashMap<>(); + + /** + * Creates an interpolator with the cross-SDK escaping policy. + */ + public Interpolator() { + // defaultValue("") makes both missing variables and variables that resolve to null render as + // the empty string (it sets jmustache's missingIsNull=true and nullValue=""). escapeHTML(false) + // emits values verbatim, matching the JS/Python SDKs. + this.compiler = Mustache.compiler() + .escapeHTML(false) + .defaultValue(""); + } + + /** + * Renders a template with the given variables and evaluation context. + * + * @param template the template text; if {@code null} the result is {@code null} + * @param variables caller-supplied variables; may be {@code null} + * @param context the evaluation context, exposed to the template as {@code ldctx}; may be + * {@code null} + * @return the rendered string, or {@code null} if {@code template} is {@code null} + */ + public String interpolate(String template, Map variables, LDContext context) { + if (template == null) { + return null; + } + Map merged = new HashMap<>(); + if (variables != null) { + merged.putAll(variables); + } + // ldctx is added last so it always wins over any caller-supplied "ldctx" entry. + merged.put("ldctx", contextToMap(context)); + return render(template, merged); + } + + /** + * Renders a template with an already-assembled variable map (no {@code ldctx} injection). + * + * @param template the template text; if {@code null} the result is {@code null} + * @param variables the variables; may be {@code null} + * @return the rendered string, or {@code null} if {@code template} is {@code null} + */ + public String interpolate(String template, Map variables) { + if (template == null) { + return null; + } + return render(template, variables == null ? new HashMap() : variables); + } + + private String render(String template, Map variables) { + Template compiled = templateCache.computeIfAbsent(template, compiler::compile); + return compiled.execute(variables); + } + + private static Map contextToMap(LDContext context) { + if (context == null || !context.isValid()) { + return new HashMap<>(); + } + LDValue asValue = LDValue.parse(JsonSerialization.serialize(context)); + Map map = LDValueConverter.toMap(asValue); + return map == null ? new HashMap() : map; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java new file mode 100644 index 00000000..9c9c0080 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java @@ -0,0 +1,180 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; + +/** + * A collector that does not use reflection and can be used with GWT. + */ +public abstract class BasicCollector implements Mustache.Collector +{ + public Iterator toIterator (final Object value) { + if (value instanceof Iterable) { + return ((Iterable)value).iterator(); + } + if (value instanceof Iterator) { + return (Iterator)value; + } + if (value.getClass().isArray()) { + final ArrayHelper helper = arrayHelper(value); + return new Iterator() { + private int _count = helper.length(value), _idx; + @Override public boolean hasNext () { return _idx < _count; } + @Override public Object next () { return helper.get(value, _idx++); } + @Override public void remove () { throw new UnsupportedOperationException(); } + }; + } + return null; + } + + public Mustache.VariableFetcher createFetcher (Object ctx, String name) { + if (ctx instanceof Mustache.CustomContext) return CUSTOM_FETCHER; + if (ctx instanceof Map) return MAP_FETCHER; + + // if the name looks like a number, potentially use one of our 'indexing' fetchers + char c = name.charAt(0); + if (c >= '0' && c <= '9') { + if (ctx instanceof List) return LIST_FETCHER; + if (ctx instanceof Iterator) return ITER_FETCHER; + if (ctx.getClass().isArray()) return arrayHelper(ctx); + } + + return null; + } + + /** This should return a thread-safe map, either {@link Collections#synchronizedMap} called on + * a standard {@link Map} implementation or something like {@code ConcurrentHashMap}. */ + public abstract Map createFetcherCache (); + + protected static ArrayHelper arrayHelper (Object ctx) { + if (ctx instanceof Object[]) return OBJECT_ARRAY_HELPER; + if (ctx instanceof boolean[]) return BOOLEAN_ARRAY_HELPER; + if (ctx instanceof byte[]) return BYTE_ARRAY_HELPER; + if (ctx instanceof char[]) return CHAR_ARRAY_HELPER; + if (ctx instanceof short[]) return SHORT_ARRAY_HELPER; + if (ctx instanceof int[]) return INT_ARRAY_HELPER; + if (ctx instanceof long[]) return LONG_ARRAY_HELPER; + if (ctx instanceof float[]) return FLOAT_ARRAY_HELPER; + if (ctx instanceof double[]) return DOUBLE_ARRAY_HELPER; + return null; + } + + protected static final Mustache.VariableFetcher CUSTOM_FETCHER = new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + Mustache.CustomContext custom = (Mustache.CustomContext)ctx; + Object val = custom.get(name); + return val == null ? Template.NO_FETCHER_FOUND : val; + } + @Override public String toString () { + return "CUSTOM_FETCHER"; + } + }; + + protected static final Mustache.VariableFetcher MAP_FETCHER = new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + Map map = (Map)ctx; + if (map.containsKey(name)) return map.get(name); + // special case to allow map entry set to be iterated over + if ("entrySet".equals(name)) return map.entrySet(); + return Template.NO_FETCHER_FOUND; + } + @Override public String toString () { + return "MAP_FETCHER"; + } + }; + + protected static final Mustache.VariableFetcher LIST_FETCHER = new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + try { + return ((List)ctx).get(Integer.parseInt(name)); + } catch (NumberFormatException nfe) { + return Template.NO_FETCHER_FOUND; + } catch (IndexOutOfBoundsException e) { + return Template.NO_FETCHER_FOUND; + } + } + @Override public String toString () { + return "LIST_FETCHER"; + } + }; + + protected static final Mustache.VariableFetcher ITER_FETCHER = new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + try { + Iterator iter = (Iterator)ctx; + for (int ii = 0, ll = Integer.parseInt(name); ii < ll; ii++) iter.next(); + return iter.next(); + } catch (NumberFormatException nfe) { + return Template.NO_FETCHER_FOUND; + } catch (NoSuchElementException e) { + return Template.NO_FETCHER_FOUND; + } + } + @Override public String toString () { + return "ITER_FETCHER"; + } + }; + + protected static abstract class ArrayHelper implements Mustache.VariableFetcher { + public Object get (Object ctx, String name) throws Exception { + try { + return get(ctx, Integer.parseInt(name)); + } catch (NumberFormatException nfe) { + return Template.NO_FETCHER_FOUND; + } catch (ArrayIndexOutOfBoundsException e) { + return Template.NO_FETCHER_FOUND; + } + } + public abstract int length (Object ctx); + protected abstract Object get (Object ctx, int index); + } + + protected static final ArrayHelper OBJECT_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((Object[])ctx)[index]; } + @Override public int length (Object ctx) { return ((Object[])ctx).length; } + }; + protected static final ArrayHelper BOOLEAN_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((boolean[])ctx)[index]; } + @Override public int length (Object ctx) { return ((boolean[])ctx).length; } + }; + protected static final ArrayHelper BYTE_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((byte[])ctx)[index]; } + @Override public int length (Object ctx) { return ((byte[])ctx).length; } + }; + protected static final ArrayHelper CHAR_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((char[])ctx)[index]; } + @Override public int length (Object ctx) { return ((char[])ctx).length; } + }; + protected static final ArrayHelper SHORT_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((short[])ctx)[index]; } + @Override public int length (Object ctx) { return ((short[])ctx).length; } + }; + protected static final ArrayHelper INT_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((int[])ctx)[index]; } + @Override public int length (Object ctx) { return ((int[])ctx).length; } + }; + protected static final ArrayHelper LONG_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((long[])ctx)[index]; } + @Override public int length (Object ctx) { return ((long[])ctx).length; } + }; + protected static final ArrayHelper FLOAT_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((float[])ctx)[index]; } + @Override public int length (Object ctx) { return ((float[])ctx).length; } + }; + protected static final ArrayHelper DOUBLE_ARRAY_HELPER = new ArrayHelper() { + @Override protected Object get (Object ctx, int index) { return ((double[])ctx)[index]; } + @Override public int length (Object ctx) { return ((double[])ctx).length; } + }; +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java new file mode 100644 index 00000000..2a638736 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java @@ -0,0 +1,149 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The default collector used by JMustache. + */ +public class DefaultCollector extends BasicCollector +{ + @Override + public Mustache.VariableFetcher createFetcher (Object ctx, String name) { + Mustache.VariableFetcher fetcher = super.createFetcher(ctx, name); + if (fetcher != null) return fetcher; + + // first check for a getter which provides the value + Class cclass = ctx.getClass(); + final Method m = getMethod(cclass, name); + if (m != null) { + return new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + return m.invoke(ctx); + } + }; + } + + // next check for a getter which provides the value + final Field f = getField(cclass, name); + if (f != null) { + return new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + return f.get(ctx); + } + }; + } + + // finally check for a default interface method which provides the value (this is left to + // last because it's much more expensive and hopefully something already matched above) + final Method im = getIfaceMethod(cclass, name); + if (im != null) { + return new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + return im.invoke(ctx); + } + }; + } + + return null; + } + + @Override + public Map createFetcherCache () { + return new ConcurrentHashMap(); + } + + protected Method getMethod (Class clazz, String name) { + // first check up the superclass chain + for (Class cc = clazz; cc != null && cc != Object.class; cc = cc.getSuperclass()) { + Method m = getMethodOn(cc, name); + if (m != null) return m; + } + return null; + } + + protected Method getIfaceMethod (Class clazz, String name) { + // enumerate the transitive closure of all interfaces implemented by clazz + Set> ifaces = new LinkedHashSet>(); + for (Class cc = clazz; cc != null && cc != Object.class; cc = cc.getSuperclass()) { + addIfaces(ifaces, cc, false); + } + // now search those in the order that we found them + for (Class iface : ifaces) { + Method m = getMethodOn(iface, name); + if (m != null) return m; + } + return null; + } + + private void addIfaces (Set> ifaces, Class clazz, boolean isIface) { + if (isIface) ifaces.add(clazz); + for (Class iface : clazz.getInterfaces()) addIfaces(ifaces, iface, true); + } + + protected Method getMethodOn (Class clazz, String name) { + Method m; + try { + m = clazz.getDeclaredMethod(name); + if (!m.getReturnType().equals(void.class)) return makeAccessible(m); + } catch (Exception e) { + // fall through + } + + String upperName = Character.toUpperCase(name.charAt(0)) + name.substring(1); + try { + m = clazz.getDeclaredMethod("get" + upperName); + if (!m.getReturnType().equals(void.class)) return makeAccessible(m); + } catch (Exception e) { + // fall through + } + + try { + m = clazz.getDeclaredMethod("is" + upperName); + if (m.getReturnType().equals(boolean.class) || + m.getReturnType().equals(Boolean.class)) return makeAccessible(m); + } catch (Exception e) { + // fall through + } + + return null; + } + + private Method makeAccessible (Method m) { + if (!m.isAccessible()) m.setAccessible(true); + return m; + } + + protected Field getField (Class clazz, String name) { + Field f; + try { + f = clazz.getDeclaredField(name); + if (!f.isAccessible()) { + f.setAccessible(true); + } + return f; + } catch (Exception e) { + // fall through + } + + Class sclass = clazz.getSuperclass(); + if (sclass != Object.class && sclass != null) { + return getField(clazz.getSuperclass(), name); + } + return null; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java new file mode 100644 index 00000000..914b2712 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java @@ -0,0 +1,47 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +/** + * Defines some standard {@link Mustache.Escaper}s. + */ +public class Escapers +{ + /** Escapes HTML entities. */ + public static final Mustache.Escaper HTML = simple(new String[][] { + { "&", "&" }, + { "'", "'" }, + { "\"", """ }, + { "<", "<" }, + { ">", ">" }, + { "`", "`" }, + { "=", "=" } + }); + + /** An escaper that does no escaping. */ + public static final Mustache.Escaper NONE = new Mustache.Escaper() { + @Override public String escape (String text) { + return text; + } + }; + + /** Returns an escaper that replaces a list of text sequences with canned replacements. + * @param repls a list of {@code (text, replacement)} pairs. */ + public static Mustache.Escaper simple (final String[]... repls) { + return new Mustache.Escaper() { + @Override public String escape (String text) { + for (String[] escape : repls) { + text = text.replace(escape[0], escape[1]); + } + return text; + } + }; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java new file mode 100644 index 00000000..5e6ea604 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java @@ -0,0 +1,1048 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Provides Mustache templating services. + * + *

Basic usage: + *

{@code
+ * String source = "Hello {{arg}}!";
+ * Template tmpl = Mustache.compiler().compile(source);
+ * Map context = new HashMap();
+ * context.put("arg", "world");
+ * tmpl.execute(context); // returns "Hello world!"
+ * }
+ */ +public class Mustache { + + /** Compiles templates into executable form. See {@link Mustache}. */ + public static class Compiler { + + /** Whether or not standards mode is enabled. */ + public final boolean standardsMode; + + /** Whether or not to throw an exception when a section resolves to a missing value. If + * false, the section is simply omitted (or included in the case of inverse sections). If + * true, a {@code MustacheException} is thrown. */ + public final boolean strictSections; + + /** A value to use when a variable resolves to null. If this value is null (which is the + * default null value), an exception will be thrown. If {@link #missingIsNull} is also + * true, this value will be used when a variable cannot be resolved. + * + *

If the nullValue contains a substring {@code {{name}}}, then this substring will be + * replaced by name of the variable. For example, if nullValue is {@code ?{{name}}?} and + * the missing variable is {@code foo}, then string {@code ?foo?} will be used.

*/ + public final String nullValue; + + /** If this value is true, missing variables will be treated like variables that return + * null. {@link #nullValue} will be used in their place, assuming {@link #nullValue} is + * configured to a non-null value. */ + public final boolean missingIsNull; + + /** If this value is true, empty string will be treated as a false value, as in JavaScript + * mustache implementation. Default is false. */ + public final boolean emptyStringIsFalse; + + /** If this value is true, zero will be treated as a false value, as in JavaScript + * mustache implementation. Default is false. */ + public final boolean zeroIsFalse; + + /** Handles converting objects to strings when rendering a template. The default formatter + * uses {@link String#valueOf}. */ + public final Formatter formatter; + + /** Handles escaping characters in substituted text. */ + public final Escaper escaper; + + /** The template loader in use during this compilation. */ + public final TemplateLoader loader; + + /** The collector used by templates compiled with this compiler. */ + public final Collector collector; + + /** The delimiters used by default in templates compiled with this compiler. */ + public final Delims delims; + + /** Compiles the supplied template into a repeatedly executable intermediate form. */ + public Template compile (String template) { + return compile(new StringReader(template)); + } + + /** Compiles the supplied template into a repeatedly executable intermediate form. */ + public Template compile (Reader source) { + return Mustache.compile(source, this); + } + + /** Returns a compiler that either does or does not escape HTML by default. Note: this + * overrides any escaper set via {@link #withEscaper}. */ + public Compiler escapeHTML (boolean escapeHTML) { + return withEscaper(escapeHTML ? Escapers.HTML : Escapers.NONE); + } + + /** Returns a compiler that either does or does not use standards mode. Standards mode + * disables the non-standard JMustache extensions like looking up missing names in a parent + * context. */ + public Compiler standardsMode (boolean standardsMode) { + return new Compiler(standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + this.formatter, this.escaper, this.loader, this.collector, + this.delims); + } + + /** Returns a compiler that throws an exception when a section references a missing value + * ({@code true}) or treats a missing value as {@code false} ({@code false}, the default). + */ + public Compiler strictSections (boolean strictSections) { + return new Compiler(this.standardsMode, strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + this.formatter, this.escaper, this.loader, this.collector, + this.delims); + } + + /** Returns a compiler that will use the given value for any variable that is missing, or + * otherwise resolves to null. This is like {@link #nullValue} except that it returns the + * supplied default for missing keys and existing keys that return null values. */ + public Compiler defaultValue (String defaultValue) { + return new Compiler(this.standardsMode, this.strictSections, defaultValue, true, + this.emptyStringIsFalse, this.zeroIsFalse, this.formatter, + this.escaper, this.loader, this.collector, this.delims); + } + + /** Returns a compiler that will use the given value for any variable that resolves to + * null, but will still raise an exception for variables for which an accessor cannot be + * found. This is like {@link #defaultValue} except that it differentiates between missing + * accessors, and accessors that exist but return null. + *
    + *
  • In the case of a Java object being used as a context, if no field or method can be + * found for a variable, an exception will be raised.
  • + *
  • In the case of a {@link Map} being used as a context, if the map does not contain + * a mapping for a variable, an exception will be raised. If the map contains a mapping + * which maps to {@code null}, then {@code nullValue} is used.
  • + *
*/ + public Compiler nullValue (String nullValue) { + return new Compiler(this.standardsMode, this.strictSections, nullValue, false, + this.emptyStringIsFalse, this.zeroIsFalse, this.formatter, + this.escaper, this.loader, this.collector, this.delims); + } + + /** Returns a compiler that will treat empty string as a false value if parameter is true. */ + public Compiler emptyStringIsFalse (boolean emptyStringIsFalse) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, emptyStringIsFalse, this.zeroIsFalse, + this.formatter, this.escaper, this.loader, this.collector, + this.delims); + } + + /** Returns a compiler that will treat zero as a false value if parameter is true. */ + public Compiler zeroIsFalse (boolean zeroIsFalse) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, zeroIsFalse, + this.formatter, this.escaper, this.loader, this.collector, + this.delims); + } + + /** Configures the {@link Formatter} used to turn objects into strings. */ + public Compiler withFormatter (Formatter formatter) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + formatter, this.escaper, this.loader, this.collector, this.delims); + } + + /** Configures the {@link Escaper} used to escape substituted text. */ + public Compiler withEscaper (Escaper escaper) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + this.formatter, escaper, this.loader, this.collector, this.delims); + } + + /** Returns a compiler configured to use the supplied template loader to handle partials. */ + public Compiler withLoader (TemplateLoader loader) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + this.formatter, this.escaper, loader, this.collector, this.delims); + } + + /** Returns a compiler configured to use the supplied collector. */ + public Compiler withCollector (Collector collector) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + this.formatter, this.escaper, this.loader, collector, this.delims); + } + + /** Returns a compiler configured to use the supplied delims as default delimiters. + * @param delims a string of the form {@code AB CD} or {@code A D} where A and B are + * opening delims and C and D are closing delims. */ + public Compiler withDelims (String delims) { + return new Compiler(this.standardsMode, this.strictSections, this.nullValue, + this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse, + this.formatter, this.escaper, this.loader, this.collector, + new Delims().updateDelims(delims)); + } + + /** Returns the value to use in the template for the null-valued property {@code name}. See + * {@link #nullValue} for more details. */ + public String computeNullValue (String name) { + return (nullValue == null) ? null : nullValue.replace("{{name}}", name); + } + + /** Returns true if the supplied value is "falsey". If {@link #emptyStringIsFalse} is true, + * then empty strings are considered falsey. If {@link #zeroIsFalse} is true, then zero + * values are considered falsey. */ + public boolean isFalsey (Object value) { + return ((emptyStringIsFalse && "".equals(formatter.format(value))) || + (zeroIsFalse && (value instanceof Number) && ((Number)value).longValue() == 0)); + } + + /** Loads and compiles the template {@code name} using this compiler's configured template + * loader. Note that this does no caching: the caller should cache the loaded template if + * they expect to use it multiple times. + * @return the compiled template. + * @throw MustacheException if the template could not be loaded (due to I/O exception) or + * compiled (due to syntax error, etc.). + */ + public Template loadTemplate (String name) throws MustacheException { + Reader tin = null; + try { + tin = loader.getTemplate(name); + return compile(tin); + } catch (Exception e) { + if (e instanceof RuntimeException) { + throw (RuntimeException)e; + } else { + throw new MustacheException("Unable to load template: " + name, e); + } + } finally { + if (tin != null) try { + tin.close(); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } + } + + protected Compiler (boolean standardsMode, boolean strictSections, String nullValue, + boolean missingIsNull, boolean emptyStringIsFalse, boolean zeroIsFalse, + Formatter formatter, Escaper escaper, TemplateLoader loader, + Collector collector, Delims delims) { + this.standardsMode = standardsMode; + this.strictSections = strictSections; + this.nullValue = nullValue; + this.missingIsNull = missingIsNull; + this.emptyStringIsFalse = emptyStringIsFalse; + this.zeroIsFalse = zeroIsFalse; + this.formatter = formatter; + this.escaper = escaper; + this.loader = loader; + this.collector = collector; + this.delims = delims; + } + } + + /** Handles converting objects to strings when rendering templates. */ + public interface Formatter { + + /** Converts {@code value} to a string for inclusion in a template. */ + String format (Object value); + } + + /** Handles lambdas. */ + public interface Lambda { + + /** Executes this lambda on the supplied template fragment. The lambda should write its + * results to {@code out}. + * + * @param frag the fragment of the template that was passed to the lambda. + * @param out the writer to which the lambda should write its output. + */ + void execute (Template.Fragment frag, Writer out) throws IOException; + } + + /** Handles lambdas that are also invoked for inverse sections.. */ + public interface InvertibleLambda extends Lambda { + + /** Executes this lambda on the supplied template fragment, when the lambda is used in an + * inverse section. The lambda should write its results to {@code out}. + * + * @param frag the fragment of the template that was passed to the lambda. + * @param out the writer to which the lambda should write its output. + */ + void executeInverse (Template.Fragment frag, Writer out) throws IOException; + } + + /** Reads variables from context objects. */ + public interface VariableFetcher { + + /** Reads the so-named variable from the supplied context object. */ + Object get (Object ctx, String name) throws Exception; + } + + /** Handles escaping characters in substituted text. */ + public interface Escaper { + + /** Returns {@code raw} with the appropriate characters replaced with escape sequences. */ + String escape (String raw); + } + + /** Handles loading partial templates. */ + public interface TemplateLoader { + + /** Returns a reader for the template with the supplied name. + * Reader will be closed by callee. + * @throws Exception if the template could not be loaded for any reason. */ + Reader getTemplate (String name) throws Exception; + } + + /** Handles interpreting objects as collections. */ + public interface Collector { + + /** Returns an iterator that can iterate over the supplied value, or null if the value is + * not a collection. */ + Iterator toIterator (final Object value); + + /** Creates a fetcher for a so-named variable in the supplied context object, which will + * never be null. The fetcher will be cached and reused for future contexts for which + * {@code octx.getClass().equals(nctx.getClass()}. */ + VariableFetcher createFetcher (Object ctx, String name); + + /** Creates a map to be used to cache {@link VariableFetcher} instances. The GWT-compatible + * collector returns a HashMap here, but the reflection based fetcher (which only works on + * the JVM and Android, returns a concurrent hashmap. */ + Map createFetcherCache (); + } + + /** + * Provides a means to implement custom logic for variable lookup. If a context object + * implements this interface, its {@code get} method will be used to look up variables instead + * of the usual methods. + * + * This is simpler than having a context implement {@link Map} which would require that it also + * support the {@link Map#entrySet} method for iteration. A {@code CustomContext} object cannot + * be used for a list section. + */ + public interface CustomContext { + + /** Fetches the value of a variable named {@code name}. */ + Object get (String name) throws Exception; + } + + /** Used to visit the tags in a template without executing it. */ + public interface Visitor { + + /** Visits a text segment. These are blocks of text that are normally just reproduced as + * is when executing a template. + * @param text the block of text. May contain newlines. + */ + void visitText (String text); + + /** Visits a variable tag. + * @param name the name of the variable. + */ + void visitVariable (String name); + + /** Visits an include (partial) tag. + * @param name the name of the partial template specified by the tag. + * @return true if the template should be resolved and visited, false to skip it. + */ + boolean visitInclude (String name); + + /** Visits a section tag. + * @param name the name of the section. + * @return true if the contents of the section should be visited, false to skip. + */ + boolean visitSection (String name); + + /** Visits an inverted section tag. + * @param name the name of the inverted section. + * @return true if the contents of the section should be visited, false to skip. + */ + boolean visitInvertedSection (String name); + } + + /** + * Returns a compiler that escapes HTML by default and does not use standards mode. + */ + public static Compiler compiler () { + return new Compiler(/*standardsMode=*/false, /*strictSections=*/false, /*nullValue=*/null, + /*missingIsNull=*/false, /*emptyStringIsFalse=*/false, + /*zeroIsFalse=*/false, DEFAULT_FORMATTER, Escapers.HTML, FAILING_LOADER, + new DefaultCollector(), new Delims()); + } + + /** + * Compiles the supplied template into a repeatedly executable intermediate form. + */ + protected static Template compile (Reader source, Compiler compiler) { + Accumulator accum = new Parser(compiler).parse(source); + return new Template(trim(accum.finish(), true), compiler); + } + + private Mustache () {} // no instantiateski + + protected static Template.Segment[] trim (Template.Segment[] segs, boolean top) { + // now that we have all of our segments, we make a pass through them to trim whitespace + // from section tags which stand alone on their lines + for (int ii = 0, ll = segs.length; ii < ll; ii++) { + Template.Segment seg = segs[ii]; + Template.Segment pseg = (ii > 0 ) ? segs[ii-1] : null; + Template.Segment nseg = (ii < ll-1) ? segs[ii+1] : null; + StringSegment prev = (pseg instanceof StringSegment) ? (StringSegment)pseg : null; + StringSegment next = (nseg instanceof StringSegment) ? (StringSegment)nseg : null; + // if we're at the top-level there are virtual "blank lines" before & after segs + boolean prevBlank = ((pseg == null && top) || (prev != null && prev.trailsBlank())); + boolean nextBlank = ((nseg == null && top) || (next != null && next.leadsBlank())); + // potentially trim around the open and close tags of a block segment + if (seg instanceof BlockSegment) { + BlockSegment block = (BlockSegment)seg; + if (prevBlank && block.firstLeadsBlank()) { + if (pseg != null) segs[ii-1] = prev.trimTrailBlank(); + block.trimFirstBlank(); + } + if (nextBlank && block.lastTrailsBlank()) { + block.trimLastBlank(); + if (nseg != null) segs[ii+1] = next.trimLeadBlank(); + } + } + // potentially trim around non-printing (comments/delims) segments + else if (seg instanceof FauxSegment) { + if (prevBlank && nextBlank) { + if (pseg != null) segs[ii-1] = prev.trimTrailBlank(); + if (nseg != null) segs[ii+1] = next.trimLeadBlank(); + } + } + } + return segs; + } + + protected static void restoreStartTag (StringBuilder text, Delims starts) { + text.insert(0, starts.start1); + if (starts.start2 != NO_CHAR) { + text.insert(1, starts.start2); + } + } + + // TODO: this method was never called, what was my intention here? + protected static boolean allowsWhitespace (char typeChar) { + return (typeChar == '=' /* change delimiters */) || (typeChar == '!' /* comment */); + } + + protected static final int TEXT = 0; + protected static final int MATCHING_START = 1; + protected static final int MATCHING_END = 2; + protected static final int TAG = 3; + + // a hand-rolled parser; whee! + protected static class Parser { + final Delims delims; + final StringBuilder text = new StringBuilder(); + + Reader source; + Accumulator accum; + + int state = TEXT; + int line = 1, column = 0; + int tagStartColumn = -1; + + public Parser (Compiler compiler) { + this.accum = new Accumulator(compiler, true); + this.delims = compiler.delims.copy(); + } + + public Accumulator parse (Reader source) { + this.source = source; + + int v; + while ((v = nextChar()) != -1) { + char c = (char)v; + ++column; // our columns start at one, so increment before parse + parseChar(c); + // if we just parsed a newline, reset the column to zero and advance line + if (c == '\n') { + column = 0; + ++line; + } + } + + // accumulate any trailing text + switch (state) { + case TAG: + restoreStartTag(text, delims); + break; + case MATCHING_END: + restoreStartTag(text, delims); + text.append(delims.end1); + break; + case MATCHING_START: + text.append(delims.start1); + break; + case TEXT: // do nothing + break; + } + accum.addTextSegment(text); + + return accum; + } + + protected void parseChar (char c) { + switch (state) { + case TEXT: + if (c == delims.start1) { + state = MATCHING_START; + tagStartColumn = column; + if (delims.start2 == NO_CHAR) { + parseChar(NO_CHAR); + } + } else { + text.append(c); + } + break; + + case MATCHING_START: + if (c == delims.start2) { + accum.addTextSegment(text); + state = TAG; + } else { + text.append(delims.start1); + state = TEXT; + parseChar(c); + } + break; + + case TAG: + if (c == delims.end1) { + state = MATCHING_END; + if (delims.end2 == NO_CHAR) { + parseChar(NO_CHAR); + } + + } else if (c == delims.start1 && text.length() > 0 && text.charAt(0) != '!') { + // if we've already matched some tag characters and we see a new start tag + // character (e.g. "{{foo {" but not "{{{"), treat the already matched text as + // plain text and start matching a new tag from this point, unless we're in + // a comment tag. + restoreStartTag(text, delims); + accum.addTextSegment(text); + tagStartColumn = column; + if (delims.start2 == NO_CHAR) { + accum.addTextSegment(text); + state = TAG; + } else { + state = MATCHING_START; + } + + } else { + text.append(c); + } + break; + + case MATCHING_END: + if (c == delims.end2) { + if (text.charAt(0) == '=') { + delims.updateDelims(text.substring(1, text.length()-1)); + text.setLength(0); + accum.addFauxSegment(); // for newline trimming + } else { + // if the delimiters are {{ and }}, and the tag starts with {{{ then + // require that it end with }}} and disable escaping + if (delims.isStaches() && text.charAt(0) == delims.start1) { + // we've only parsed }} at this point, so we have to slurp in another + // character from the input stream and check it + int end3 = nextChar(); + if (end3 != '}') { + String got = (end3 == -1) ? "" : String.valueOf((char)end3); + throw new MustacheParseException( + "Invalid triple-mustache tag: {{" + text + "}}" + got, line); + } + // convert it into (equivalent) {{&text}} which addTagSegment handles + text.replace(0, 1, "&"); + } + // process the tag between the mustaches + accum = accum.addTagSegment(text, line); + } + state = TEXT; + + } else { + text.append(delims.end1); + state = TAG; + parseChar(c); + } + break; + } + } + + protected int nextChar () { + try { + return source.read(); + } catch (IOException ioe) { + throw new MustacheException(ioe); + } + } + } + + protected static class Delims { + public char start1 = '{', end1 = '}'; + public char start2 = '{', end2 = '}'; + + public boolean isStaches () { + return start1 == '{' && start2 == '{' && end1 == '}' && end2 == '}'; + } + + public Delims updateDelims (String dtext) { + String[] delims = dtext.split(" "); + if (delims.length != 2) throw new MustacheException(errmsg(dtext)); + + switch (delims[0].length()) { + case 1: + start1 = delims[0].charAt(0); + start2 = NO_CHAR; + break; + case 2: + start1 = delims[0].charAt(0); + start2 = delims[0].charAt(1); + break; + default: + throw new MustacheException(errmsg(dtext)); + } + + switch (delims[1].length()) { + case 1: + end1 = delims[1].charAt(0); + end2 = NO_CHAR; + break; + case 2: + end1 = delims[1].charAt(0); + end2 = delims[1].charAt(1); + break; + default: + throw new MustacheException(errmsg(dtext)); + } + return this; + } + + public void addTag (char prefix, String name, StringBuilder into) { + into.append(start1); + into.append(start2); + if (prefix != ' ') into.append(prefix); + into.append(name); + into.append(end1); + into.append(end2); + } + + Delims copy () { + Delims d = new Delims(); + d.start1 = start1; + d.start2 = start2; + d.end1 = end1; + d.end2 = end2; + return d; + } + + private static String errmsg (String dtext) { + return "Invalid delimiter configuration '" + dtext + "'. Must be of the " + + "form {{=1 2=}} or {{=12 34=}} where 1, 2, 3 and 4 are delimiter chars."; + } + } + + protected static class Accumulator { + public Accumulator (Compiler compiler, boolean topLevel) { + _comp = compiler; + _topLevel = topLevel; + } + + public void addTextSegment (StringBuilder text) { + if (text.length() > 0) { + _segs.add(new StringSegment(text.toString(), _segs.isEmpty() && _topLevel)); + text.setLength(0); + } + } + + public Accumulator addTagSegment (final StringBuilder accum, final int tagLine) { + final Accumulator outer = this; + String tag = accum.toString().trim(); + final String tag1 = tag.substring(1).trim(); + accum.setLength(0); + + switch (tag.charAt(0)) { + case '#': + requireNoNewlines(tag, tagLine); + return new Accumulator(_comp, false) { + @Override public Template.Segment[] finish () { + throw new MustacheParseException( + "Section missing close tag '" + tag1 + "'", tagLine); + } + @Override protected Accumulator addCloseSectionSegment (String itag, int line) { + requireSameName(tag1, itag, line); + outer._segs.add(new SectionSegment(_comp, itag, super.finish(), tagLine)); + return outer; + } + }; + + case '>': + _segs.add(new IncludedTemplateSegment(_comp, tag1)); + return this; + + case '^': + requireNoNewlines(tag, tagLine); + return new Accumulator(_comp, false) { + @Override public Template.Segment[] finish () { + throw new MustacheParseException( + "Inverted section missing close tag '" + tag1 + "'", tagLine); + } + @Override protected Accumulator addCloseSectionSegment (String itag, int line) { + requireSameName(tag1, itag, line); + outer._segs.add(new InvertedSegment(_comp, itag, super.finish(), tagLine)); + return outer; + } + }; + + case '/': + requireNoNewlines(tag, tagLine); + return addCloseSectionSegment(tag1, tagLine); + + case '!': + // comment!, ignore + _segs.add(new FauxSegment()); // for whitespace trimming + return this; + + case '&': + requireNoNewlines(tag, tagLine); + _segs.add(new VariableSegment(tag1, tagLine, _comp.formatter, Escapers.NONE)); + return this; + + default: + requireNoNewlines(tag, tagLine); + _segs.add(new VariableSegment(tag, tagLine, _comp.formatter, _comp.escaper)); + return this; + } + } + + public void addFauxSegment () { + _segs.add(new FauxSegment()); + } + + public Template.Segment[] finish () { + return _segs.toArray(new Template.Segment[_segs.size()]); + } + + protected Accumulator addCloseSectionSegment (String tag, int line) { + throw new MustacheParseException( + "Section close tag with no open tag '" + tag + "'", line); + } + + protected static void requireNoNewlines (String tag, int line) { + if (tag.indexOf('\n') != -1 || tag.indexOf('\r') != -1) { + throw new MustacheParseException( + "Invalid tag name: contains newline '" + tag + "'", line); + } + } + + protected static void requireSameName (String name1, String name2, int line) { + if (!name1.equals(name2)) { + throw new MustacheParseException("Section close tag with mismatched open tag '" + + name2 + "' != '" + name1 + "'", line); + } + } + + protected final Compiler _comp; + protected final boolean _topLevel; + protected final List _segs = new ArrayList(); + } + + /** A simple segment that reproduces a string. */ + protected static class StringSegment extends Template.Segment { + public StringSegment (String text, boolean first) { + this(text, blankPos(text, true, first), blankPos(text, false, first)); + } + + public StringSegment (String text, int leadBlank, int trailBlank) { + assert leadBlank >= -1; + assert trailBlank >= -1; + _text = text; + _leadBlank = leadBlank; + _trailBlank = trailBlank; + } + + public boolean leadsBlank () { return _leadBlank != -1; } + public boolean trailsBlank () { return _trailBlank != -1; } + + public StringSegment trimLeadBlank () { + if (_leadBlank == -1) return this; + int lpos = _leadBlank+1, newTrail = _trailBlank == -1 ? -1 : _trailBlank-lpos; + return new StringSegment(_text.substring(lpos), -1, newTrail); + } + public StringSegment trimTrailBlank () { + return _trailBlank == -1 ? this : new StringSegment( + _text.substring(0, _trailBlank), _leadBlank, -1); + } + + @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { + write(out, _text); + } + @Override public void decompile (Delims delims, StringBuilder into) { + into.append(_text); + } + @Override public void visit (Visitor visitor) { + visitor.visitText(_text); + } + @Override public String toString () { + return "Text(" + _text.replace("\r", "\\r").replace("\n", "\\n") + ")" + + _leadBlank + "/" + _trailBlank; + } + + private static int blankPos (String text, boolean leading, boolean first) { + int len = text.length(); + for (int ii = leading ? 0 : len-1, ll = leading ? len : -1, dd = leading ? 1 : -1; + ii != ll; ii += dd) { + char c = text.charAt(ii); + if (c == '\n') return leading ? ii : ii+1; + if (!Character.isWhitespace(c)) return -1; + } + // if this is the first string segment and we're looking for trailing whitespace, a + // totally blank segment (but which lacks a newline) is all trailing whitespace + return (leading || !first) ? -1 : 0; + } + + protected final String _text; + protected final int _leadBlank, _trailBlank; + } + + /** A segment that loads and executes a sub-template. */ + protected static class IncludedTemplateSegment extends Template.Segment { + public IncludedTemplateSegment (Compiler compiler, String name) { + _comp = compiler; + _name = name; + } + @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { + // we must take care to preserve our context rather than creating a new one, which + // would happen if we just called execute() with ctx.data + getTemplate().executeSegs(ctx, out); + } + @Override public void decompile (Delims delims, StringBuilder into) { + delims.addTag('>', _name, into); + } + @Override public void visit (Visitor visitor) { + if (visitor.visitInclude(_name)) { + getTemplate().visit(visitor); + } + } + protected Template getTemplate () { + // we compile our template lazily to avoid infinie recursion if a template includes + // itself (see issue #13) + if (_template == null) { + _template = _comp.loadTemplate(_name); + } + return _template; + } + protected final Compiler _comp; + protected final String _name; + private Template _template; + } + + /** A helper class for named segments. */ + protected static abstract class NamedSegment extends Template.Segment { + protected NamedSegment (String name, int line) { + _name = name; + _line = line; + } + protected final String _name; + protected final int _line; + } + + /** A segment that substitutes the contents of a variable. */ + protected static class VariableSegment extends NamedSegment { + public VariableSegment (String name, int line, Formatter formatter, Escaper escaper) { + super(name, line); + _formatter = formatter; + _escaper = escaper; + } + @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { + Object value = tmpl.getValueOrDefault(ctx, _name, _line); + if (value == null) { + String msg = Template.isThisName(_name) ? + "Resolved '.' to null (which is disallowed), on line " + _line : + "No key, method or field with name '" + _name + "' on line " + _line; + throw new MustacheException.Context(msg, _name, _line); + } + write(out, _escaper.escape(_formatter.format(value))); + } + @Override public void decompile (Delims delims, StringBuilder into) { + delims.addTag(' ', _name, into); + } + @Override public void visit (Visitor visitor) { + visitor.visitVariable(_name); + } + @Override public String toString () { + return "Var(" + _name + ":" + _line + ")"; + } + protected final Formatter _formatter; + protected final Escaper _escaper; + } + + /** A helper class for block segments. */ + protected static abstract class BlockSegment extends NamedSegment { + public boolean firstLeadsBlank () { + if (_segs.length == 0 || !(_segs[0] instanceof StringSegment)) return false; + return ((StringSegment)_segs[0]).leadsBlank(); + } + public void trimFirstBlank () { + _segs[0] = ((StringSegment)_segs[0]).trimLeadBlank(); + } + + public boolean lastTrailsBlank () { + int lastIdx = _segs.length-1; + if (_segs.length == 0 || !(_segs[lastIdx] instanceof StringSegment)) return false; + return ((StringSegment)_segs[lastIdx]).trailsBlank(); + } + public void trimLastBlank () { + int idx = _segs.length-1; + _segs[idx] = ((StringSegment)_segs[idx]).trimTrailBlank(); + } + + protected BlockSegment (String name, Template.Segment[] segs, int line) { + super(name, line); + _segs = trim(segs, false); + } + protected void executeSegs (Template tmpl, Template.Context ctx, Writer out) { + for (Template.Segment seg : _segs) { + seg.execute(tmpl, ctx, out); + } + } + + protected final Template.Segment[] _segs; + } + + /** A segment that represents a section. */ + protected static class SectionSegment extends BlockSegment { + public SectionSegment (Compiler compiler, String name, Template.Segment[] segs, int line) { + super(name, segs, line); + _comp = compiler; + } + @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { + Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null + Iterator iter = _comp.collector.toIterator(value); + if (iter != null) { + int index = 0; + while (iter.hasNext()) { + Object elem = iter.next(); + boolean onFirst = (index == 0), onLast = !iter.hasNext(); + executeSegs(tmpl, ctx.nest(elem, ++index, onFirst, onLast), out); + } + } else if (value instanceof Boolean) { + if ((Boolean)value) { + executeSegs(tmpl, ctx, out); + } + } else if (value instanceof Lambda) { + try { + ((Lambda)value).execute(tmpl.createFragment(_segs, ctx), out); + } catch (IOException ioe) { + throw new MustacheException(ioe); + } + } else if (_comp.isFalsey(value)) { + // omit the section + } else { + executeSegs(tmpl, ctx.nest(value), out); + } + } + @Override public void decompile (Delims delims, StringBuilder into) { + delims.addTag('#', _name, into); + for (Template.Segment seg : _segs) seg.decompile(delims, into); + delims.addTag('/', _name, into); + } + @Override public void visit (Visitor visitor) { + if (visitor.visitSection(_name)) { + for (Template.Segment seg : _segs) { + seg.visit(visitor); + } + } + } + @Override public String toString () { + return "Section(" + _name + ":" + _line + "): " + Arrays.toString(_segs); + } + protected final Compiler _comp; + } + + /** A segment that represents an inverted section. */ + protected static class InvertedSegment extends BlockSegment { + public InvertedSegment (Compiler compiler, String name, Template.Segment[] segs, int line) { + super(name, segs, line); + _comp = compiler; + } + @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { + Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null + Iterator iter = _comp.collector.toIterator(value); + if (iter != null) { + if (!iter.hasNext()) { + executeSegs(tmpl, ctx, out); + } + } else if (value instanceof Boolean) { + if (!(Boolean)value) { + executeSegs(tmpl, ctx, out); + } + } else if (value instanceof InvertibleLambda) { + try { + ((InvertibleLambda)value).executeInverse(tmpl.createFragment(_segs, ctx), out); + } catch (IOException ioe) { + throw new MustacheException(ioe); + } + } else if (_comp.isFalsey(value)) { + executeSegs(tmpl, ctx, out); + } // TODO: fail? + } + @Override public void decompile (Delims delims, StringBuilder into) { + delims.addTag('^', _name, into); + for (Template.Segment seg : _segs) seg.decompile(delims, into); + delims.addTag('/', _name, into); + } + @Override public void visit (Visitor visitor) { + if (visitor.visitInvertedSection(_name)) { + for (Template.Segment seg : _segs) { + seg.visit(visitor); + } + } + } + @Override public String toString () { + return "Inverted(" + _name + ":" + _line + "): " + Arrays.toString(_segs); + } + protected final Compiler _comp; + } + + protected static class FauxSegment extends Template.Segment { + @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {} // nada + @Override public void decompile (Delims delims, StringBuilder into) {} // nada + @Override public void visit (Visitor visit) {} + @Override public String toString () { return "Faux"; } + } + + /** Used when we have only a single character delimiter. */ + protected static final char NO_CHAR = Character.MIN_VALUE; + + protected static final TemplateLoader FAILING_LOADER = new TemplateLoader() { + public Reader getTemplate (String name) { + throw new UnsupportedOperationException("Template loading not configured"); + } + }; + + protected static final Formatter DEFAULT_FORMATTER = new Formatter() { + public String format (Object value) { + return String.valueOf(value); + } + }; +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java new file mode 100644 index 00000000..35e777f3 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java @@ -0,0 +1,50 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +/** + * An exception thrown when an error occurs parsing or executing a Mustache template. + */ +public class MustacheException extends RuntimeException +{ + /** An exception thrown if we encounter a context error (e.g. a missing variable) while + * compiling or executing a template. */ + public static class Context extends MustacheException { + /** The key that caused the problem. */ + public final String key; + + /** The line number of the template on which the problem occurred. */ + public final int lineNo; + + public Context (String message, String key, int lineNo) { + super(message); + this.key = key; + this.lineNo = lineNo; + } + + public Context (String message, String key, int lineNo, Throwable cause) { + super(message, cause); + this.key = key; + this.lineNo = lineNo; + } + } + + public MustacheException (String message) { + super(message); + } + + public MustacheException (Throwable cause) { + super(cause); + } + + public MustacheException (String message, Throwable cause) { + super(message, cause); + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java new file mode 100644 index 00000000..8cd27428 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java @@ -0,0 +1,24 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +/** + * An exception thrown if we encounter an error while parsing a template. + */ +public class MustacheParseException extends MustacheException +{ + public MustacheParseException (String message) { + super(message); + } + + public MustacheParseException (String message, int lineNo) { + super(message + " @ line " + lineNo); + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java new file mode 100644 index 00000000..25dc4a9c --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java @@ -0,0 +1,441 @@ +// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne). +// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695). +// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package +// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text. +// +// +// JMustache - A Java implementation of the Mustache templating language +// http://github.com/samskivert/jmustache/blob/master/LICENSE + +package com.launchdarkly.sdk.server.ai.internal.mustache; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; + +/** + * Represents a compiled template. Templates are executed with a context to generate + * output. The context can be any tree of objects. Variables are resolved against the context. + * Given a name {@code foo}, the following mechanisms are supported for resolving its value + * (and are sought in this order): + * + *
    + *
  • If the variable has the special name {@code this} the context object itself will be + * returned. This is useful when iterating over lists. + *
  • If the object is a {@link Map}, {@link Map#get} will be called with the string {@code foo} + * as the key. + *
  • A method named {@code foo} in the supplied object (with non-void return value). + *
  • A method named {@code getFoo} in the supplied object (with non-void return value). + *
  • A field named {@code foo} in the supplied object. + *
+ * + *

The field type, method return type, or map value type should correspond to the desired + * behavior if the resolved name corresponds to a section. {@link Boolean} is used for showing or + * hiding sections without binding a sub-context. Arrays, {@link Iterator} and {@link Iterable} + * implementations are used for sections that repeat, with the context bound to the elements of the + * array, iterator or iterable. Lambdas are current unsupported, though they would be easy enough + * to add if desire exists. See the Mustache + * documentation for more details on section behavior.

+ */ +public class Template { + + /** + * Encapsulates a fragment of a template that is passed to a lambda. The fragment is bound to + * the variable context that was in effect at the time the lambda was called. + */ + public abstract class Fragment { + + /** Executes this fragment; writes its result to {@code out}. */ + public abstract void execute (Writer out); + + /** Executes this fragment with the provided context; writes its result to {@code out}. The + * provided context will be nested in the fragment's bound context. */ + public abstract void execute (Object context, Writer out); + + /** Executes {@code tmpl} using this fragment's bound context. This allows a lambda to + * resolve its fragment to a dynamically loaded template and then run that template with + * the same context as the lamda, allowing a lambda to act as a 'late bound' included + * template, i.e. you can decide which template to include based on information in the + * context. */ + public abstract void executeTemplate (Template tmpl, Writer out); + + /** Executes this fragment and returns its result as a string. */ + public String execute () { + StringWriter out = new StringWriter(); + execute(out); + return out.toString(); + } + + /** Executes this fragment with the provided context; returns its result as a string. The + * provided context will be nested in the fragment's bound context. */ + public String execute (Object context) { + StringWriter out = new StringWriter(); + execute(context, out); + return out.toString(); + } + + /** Returns the context object in effect for this fragment. The actual type of the object + * depends on the structure of the data passed to the top-level template. You know where + * your lambdas are executed, so you know what type to which to cast the context in order + * to inspect it (be that a {@code Map} or a POJO or something else). */ + public abstract Object context (); + + /** Like {@link #context()} btu returns the {@code n}th parent context object. {@code 0} + * returns the same value as {@link #context()}, {@code 1} returns the parent context, + * {@code 2} returns the grandparent and so forth. Note that if you request a parent that + * does not exist an exception will be thrown. You should only use this method when you + * know your lambda is run consistently in a context with a particular lineage. */ + public abstract Object context (int n); + + /** Decompiles the template inside this lamdba and returns an approximation of + * the original template from which it was parsed. This is not the exact character for + * character representation because the original text is not preserved because that would + * incur a huge memory penalty for all users of the library when the vast majority of + * them do not call decompile. + * + *

Limitations: + *

  • Whitespace inside tags is not preserved: i.e. {@code {{ foo.bar }}} becomes + * {@code {{foo.bar}}}. + *
  • If the delimiters are changed by the template, those are not preserved. + * The delimiters configured on the {@link Compiler} are used for all decompilation. + *
+ * + *

This feature is meant to enable use of lambdas for i18n such that you can recover + * the contents of a lambda (so long as they're simple) to use as the lookup key for a + * translation string. For example: {@code {{#i18n}}Hello {{user.name}}!{{/i18n}}} can be + * sent to an {@code i18n} lambda which can use {@code decompile} to recover the text + * {@code Hello {{user.name}}!} to be looked up in a translation dictionary. The + * translated fragment could then be compiled and cached and then executed in lieu of the + * original fragment using {@link Template.Fragment#context}. + */ + public String decompile () { + return decompile(new StringBuilder()).toString(); + } + + /** Decompiles this fragment into {@code into}. See {@link #decompile()}. + * @return {@code into} for call chaining. */ + public abstract StringBuilder decompile (StringBuilder into); + } + + /** A sentinel object that can be returned by a {@link Mustache.Collector} to indicate that a + * variable does not exist in a particular context. */ + public static final Object NO_FETCHER_FOUND = new String(""); + + /** + * Executes this template with the given context, returning the results as a string. + * @throws MustacheException if an error occurs while executing or writing the template. + */ + public String execute (Object context) throws MustacheException { + StringWriter out = new StringWriter(); + execute(context, out); + return out.toString(); + } + + /** + * Executes this template with the given context, writing the results to the supplied writer. + * @throws MustacheException if an error occurs while executing or writing the template. + */ + public void execute (Object context, Writer out) throws MustacheException { + executeSegs(new Context(context, null, 0, false, false), out); + } + + /** + * Executes this template with the supplied context and parent context, writing the results to + * the supplied writer. The parent context will be searched for variables that cannot be found + * in the main context, in the same way the main context becomes a parent context when entering + * a block. + * @throws MustacheException if an error occurs while executing or writing the template. + */ + public void execute (Object context, Object parentContext, Writer out) throws MustacheException { + Context pctx = new Context(parentContext, null, 0, false, false); + executeSegs(new Context(context, pctx, 0, false, false), out); + } + + /** + * Visits the tags in this template (via {@code visitor}) without executing it. + * @param visitor the visitor to be called back on each tag in the template. + */ + public void visit (Mustache.Visitor visitor) { + for (Segment seg : _segs) { + seg.visit(visitor); + } + } + + protected Template (Segment[] segs, Mustache.Compiler compiler) { + _segs = segs; + _compiler = compiler; + _fcache = compiler.collector.createFetcherCache(); + } + + protected void executeSegs (Context ctx, Writer out) throws MustacheException { + for (Segment seg : _segs) { + seg.execute(this, ctx, out); + } + } + + protected Fragment createFragment (final Segment[] segs, final Context currentCtx) { + return new Fragment() { + @Override public void execute (Writer out) { + execute(currentCtx, out); + } + @Override public void execute (Object context, Writer out) { + execute(currentCtx.nest(context), out); + } + @Override public void executeTemplate (Template tmpl, Writer out) { + tmpl.executeSegs(currentCtx, out); + } + @Override public Object context () { + return currentCtx.data; + } + @Override public Object context (int n) { + return context(currentCtx, n); + } + @Override public StringBuilder decompile (StringBuilder into) { + for (Segment seg : segs) seg.decompile(_compiler.delims, into); + return into; + } + private Object context (Context ctx, int n) { + return (n == 0) ? ctx.data : context(ctx.parent, n-1); + } + private void execute (Context ctx, Writer out) { + for (Segment seg : segs) { + seg.execute(Template.this, ctx, out); + } + } + }; + } + + /** + * Called by executing segments to obtain the value of the specified variable in the supplied + * context. + * + * @param ctx the context in which to look up the variable. + * @param name the name of the variable to be resolved. + * @param missingIsNull whether to fail if a variable cannot be resolved, or to return null in + * that case. + * + * @return the value associated with the supplied name or null if no value could be resolved. + */ + protected Object getValue (Context ctx, String name, int line, boolean missingIsNull) { + // handle our special variables + if (name.equals(FIRST_NAME)) { + return ctx.onFirst; + } else if (name.equals(LAST_NAME)) { + return ctx.onLast; + } else if (name.equals(INDEX_NAME)) { + return ctx.index; + } + + // if we're in standards mode, restrict ourselves to simple direct resolution (no compound + // keys, no resolving values in parent contexts) + if (_compiler.standardsMode) { + Object value = getValueIn(ctx.data, name, line); + return checkForMissing(name, line, missingIsNull, value); + } + + // first search our parent contexts for the key (even if the key is a compound key, we will + // first try to find it "whole" and only if that fails do we resolve it in parts) + for (Context pctx = ctx; pctx != null; pctx = pctx.parent) { + Object value = getValueIn(pctx.data, name, line); + if (value != NO_FETCHER_FOUND) return value; + } + // if we reach here, we found nothing in this or our parent contexts... + + // if we have a compound key, decompose the value and resolve it step by step + if (!name.equals(DOT_NAME) && name.indexOf(DOT_NAME) != -1) { + return getCompoundValue(ctx, name, line, missingIsNull); + } else { + // otherwise let checkForMissing() decide what to do + return checkForMissing(name, line, missingIsNull, NO_FETCHER_FOUND); + } + } + + /** + * Decomposes the compound key {@code name} into components and resolves the value they + * reference. + */ + protected Object getCompoundValue (Context ctx, String name, int line, boolean missingIsNull) { + String[] comps = name.split("\\."); + // we want to allow the first component of a compound key to be located in a parent + // context, but once we're selecting sub-components, they must only be resolved in the + // object that represents that component + Object data = getValue(ctx, comps[0], line, missingIsNull); + for (int ii = 1; ii < comps.length; ii++) { + if (data == NO_FETCHER_FOUND) { + if (!missingIsNull) throw new MustacheException.Context( + "Missing context for compound variable '" + name + "' on line " + line + + ". '" + comps[ii - 1] + "' was not found.", name, line); + return null; + } else if (data == null) { + return null; + } + // once we step into a composite key, we drop the ability to query our parent contexts; + // that would be weird and confusing + data = getValueIn(data, comps[ii], line); + } + return checkForMissing(name, line, missingIsNull, data); + } + + /** + * Returns the value of the specified variable, noting that it is intended to be used as the + * contents for a section. + */ + protected Object getSectionValue (Context ctx, String name, int line) { + Object value = getValue(ctx, name, line, !_compiler.strictSections); + // TODO: configurable behavior on null values? + return (value == null) ? Collections.emptyList() : value; + } + + /** + * Returns the value for the specified variable, or the configured default value if the + * variable resolves to null. See {@link #getValue}. + */ + protected Object getValueOrDefault (Context ctx, String name, int line) { + Object value = getValue(ctx, name, line, _compiler.missingIsNull); + // getValue will raise MustacheException if a variable cannot be resolved and missingIsNull + // is not configured; so we're safe to assume that any null that makes it up to this point + // can be converted to nullValue + return (value == null) ? _compiler.computeNullValue(name) : value; + } + + protected Object getValueIn (Object data, String name, int line) { + // if we're getting `.` or `this` then just return the whole context; we do this before the + // null check because it may be valid for the context to be null (if we're iterating over a + // list which contains nulls, for example) + if (isThisName(name)) return data; + + if (data == null) { + throw new NullPointerException( + "Null context for variable '" + name + "' on line " + line); + } + + Key key = new Key(data.getClass(), name); + Mustache.VariableFetcher fetcher = _fcache.get(key); + if (fetcher != null) { + try { + return fetcher.get(data, name); + } catch (Exception e) { + // zoiks! non-monomorphic call site, update the cache and try again + fetcher = _compiler.collector.createFetcher(data, key.name); + } + } else { + fetcher = _compiler.collector.createFetcher(data, key.name); + } + + // if we were unable to create a fetcher, use the NOT_FOUND_FETCHER which will return + // NO_FETCHER_FOUND to let the caller know that they can try the parent context or do le + // freak out; we still cache this fetcher to avoid repeatedly looking up and failing to + // find a fetcher in the same context (which can be expensive) + if (fetcher == null) { + fetcher = NOT_FOUND_FETCHER; + } + + try { + Object value = fetcher.get(data, name); + _fcache.put(key, fetcher); + return value; + } catch (Exception e) { + throw new MustacheException.Context( + "Failure fetching variable '" + name + "' on line " + line, name, line, e); + } + } + + protected Object checkForMissing (String name, int line, boolean missingIsNull, Object value) { + if (value == NO_FETCHER_FOUND) { + if (missingIsNull) return null; + throw new MustacheException.Context( + "No method or field with name '" + name + "' on line " + line, name, line); + } else { + return value; + } + } + + protected final Segment[] _segs; + protected final Mustache.Compiler _compiler; + protected final Map _fcache; + + protected static class Context { + public final Object data; + public final Context parent; + public final int index; + public final boolean onFirst; + public final boolean onLast; + + public Context (Object data, Context parent, int index, boolean onFirst, boolean onLast) { + this.data = data; + this.parent = parent; + this.index = index; + this.onFirst = onFirst; + this.onLast = onLast; + } + + public Context nest (Object data) { + return new Context(data, this, index, onFirst, onLast); + } + + public Context nest (Object data, int index, boolean onFirst, boolean onLast) { + return new Context(data, this, index, onFirst, onLast); + } + } + + /** A template is broken into segments. */ + protected static abstract class Segment { + abstract void execute (Template tmpl, Context ctx, Writer out); + + abstract void decompile (Mustache.Delims delims, StringBuilder into); + + abstract void visit (Mustache.Visitor visitor); + + protected static void write (Writer out, String data) { + try { + out.write(data); + } catch (IOException ioe) { + throw new MustacheException(ioe); + } + } + } + + /** Used to cache variable fetchers for a given context class, name combination. */ + protected static class Key { + public final Class cclass; + public final String name; + + public Key (Class cclass, String name) { + this.cclass = cclass; + this.name = name; + } + + @Override public int hashCode () { + return cclass.hashCode() * 31 + name.hashCode(); + } + + @Override public boolean equals (Object other) { + Key okey = (Key)other; + return okey.cclass == cclass && okey.name.equals(name); + } + + @Override public String toString () { + return cclass.getName() + ":" + name; + } + } + + protected static boolean isThisName (String name) { + return DOT_NAME.equals(name) || THIS_NAME.equals(name); + } + + protected static final String DOT_NAME = "."; + protected static final String THIS_NAME = "this"; + protected static final String FIRST_NAME = "-first"; + protected static final String LAST_NAME = "-last"; + protected static final String INDEX_NAME = "-index"; + + /** A fetcher cached for lookups that failed to find a fetcher. */ + protected static Mustache.VariableFetcher NOT_FOUND_FETCHER = new Mustache.VariableFetcher() { + public Object get (Object ctx, String name) throws Exception { + return NO_FETCHER_FOUND; + } + }; +} diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java new file mode 100644 index 00000000..88132fb9 --- /dev/null +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java @@ -0,0 +1,97 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import com.launchdarkly.sdk.LDContext; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class InterpolatorTest { + private final Interpolator interpolator = new Interpolator(); + + @Test + public void rendersSimpleVariable() { + Map vars = new HashMap<>(); + vars.put("name", "World"); + assertThat(interpolator.interpolate("Hello {{name}}", vars), is("Hello World")); + } + + @Test + public void doesNotHtmlEscape() { + Map vars = new HashMap<>(); + vars.put("x", "&\"'"); + // Matches the JS/Python policy: values are emitted verbatim, never HTML-escaped. + assertThat(interpolator.interpolate("{{x}}", vars), is("&\"'")); + } + + @Test + public void tripleStacheMatchesDoubleStache() { + Map vars = new HashMap<>(); + vars.put("x", ""); + assertThat(interpolator.interpolate("{{{x}}}", vars), is(interpolator.interpolate("{{x}}", vars))); + } + + @Test + public void missingVariableRendersEmpty() { + assertThat(interpolator.interpolate("[{{missing}}]", new HashMap()), is("[]")); + } + + @Test + public void nullVariableRendersEmpty() { + Map vars = new HashMap<>(); + vars.put("x", null); + assertThat(interpolator.interpolate("[{{x}}]", vars), is("[]")); + } + + @Test + public void nullTemplateReturnsNull() { + assertThat(interpolator.interpolate(null, new HashMap()), is(nullValue())); + assertThat(interpolator.interpolate(null, new HashMap(), LDContext.create("k")), + is(nullValue())); + } + + @Test + public void exposesContextAsLdctx() { + LDContext context = LDContext.builder("user-key") + .name("Bob") + .set("tier", "gold") + .build(); + String result = interpolator.interpolate( + "{{ldctx.kind}}/{{ldctx.key}}/{{ldctx.name}}/{{ldctx.tier}}", null, context); + assertThat(result, is("user/user-key/Bob/gold")); + } + + @Test + public void ldctxOverridesUserSuppliedValue() { + Map userLdctx = new HashMap<>(); + userLdctx.put("key", "WRONG"); + Map vars = new HashMap<>(); + vars.put("ldctx", userLdctx); + + LDContext context = LDContext.create("right-key"); + assertThat(interpolator.interpolate("{{ldctx.key}}", vars, context), is("right-key")); + } + + @Test + public void nullContextLeavesLdctxEmpty() { + assertThat(interpolator.interpolate("[{{ldctx.key}}]", null, null), is("[]")); + } + + @Test + public void cachedTemplateRendersConsistentlyAcrossInvocations() { + Map first = new HashMap<>(); + first.put("v", "one"); + Map second = new HashMap<>(); + second.put("v", "two"); + + assertThat(interpolator.interpolate("value={{v}}", first), is("value=one")); + // Second render uses the cached compiled template but the new variable map. + assertThat(interpolator.interpolate("value={{v}}", second), is("value=two")); + } +} From 0cc05bb0dde8f5bb54baa201da0e5c72a747fca5 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 10 Jun 2026 17:14:42 -0400 Subject: [PATCH 02/12] refactor: encode LDContext into ldctx map directly (AIC-2695) Build the ldctx template variable by walking the LDContext's attributes directly instead of serializing it to a JSON string and parsing it back into an LDValue. This removes the serialize-then-deserialize round trip flagged in review, following the generic-encoder shape used by the iOS SDK. Custom attribute values are still converted via LDValueConverter so nested objects/arrays remain addressable, and multi-kind contexts are exposed as {"kind":"multi", : {...}}. Co-authored-by: Cursor --- .../sdk/server/ai/internal/Interpolator.java | 42 ++++++++++++++++--- .../server/ai/internal/InterpolatorTest.java | 18 ++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) 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 93477cd6..d5570679 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,8 +1,6 @@ package com.launchdarkly.sdk.server.ai.internal; import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.server.ai.internal.mustache.Mustache; import com.launchdarkly.sdk.server.ai.internal.mustache.Template; @@ -86,12 +84,46 @@ private String render(String template, Map variables) { 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", : {...}}} + * with one nested map per individual context. + */ private static Map contextToMap(LDContext context) { if (context == null || !context.isValid()) { return new HashMap<>(); } - LDValue asValue = LDValue.parse(JsonSerialization.serialize(context)); - Map map = LDValueConverter.toMap(asValue); - return map == null ? new HashMap() : map; + if (context.isMultiple()) { + Map map = new HashMap<>(); + map.put("kind", "multi"); + int count = context.getIndividualContextCount(); + for (int i = 0; i < count; i++) { + LDContext individual = context.getIndividualContext(i); + if (individual != null) { + map.put(individual.getKind().toString(), singleContextToMap(individual)); + } + } + return map; + } + return singleContextToMap(context); + } + + private static Map singleContextToMap(LDContext context) { + Map map = new HashMap<>(); + map.put("kind", context.getKind().toString()); + map.put("key", context.getKey()); + if (context.getName() != null) { + map.put("name", context.getName()); + } + if (context.isAnonymous()) { + map.put("anonymous", true); + } + // 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/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java index 88132fb9..d7800646 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java @@ -67,6 +67,24 @@ public void exposesContextAsLdctx() { assertThat(result, is("user/user-key/Bob/gold")); } + @Test + public void exposesNestedCustomAttribute() { + LDContext context = LDContext.builder("user-key") + .set("address", com.launchdarkly.sdk.LDValue.buildObject().put("city", "Oakland").build()) + .build(); + assertThat(interpolator.interpolate("{{ldctx.address.city}}", null, context), is("Oakland")); + } + + @Test + public void exposesMultiKindContextByKind() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").name("Bob").build(), + LDContext.builder(com.launchdarkly.sdk.ContextKind.of("org"), "org-key").set("tier", "gold").build()); + String result = interpolator.interpolate( + "{{ldctx.kind}}/{{ldctx.user.key}}/{{ldctx.user.name}}/{{ldctx.org.tier}}", null, multi); + assertThat(result, is("multi/user-key/Bob/gold")); + } + @Test public void ldctxOverridesUserSuppliedValue() { Map userLdctx = new HashMap<>(); From 64069538514e4214b31dda6d4e2d70be48e0820f Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 10 Jun 2026 21:39:40 -0400 Subject: [PATCH 03/12] docs: generalize cross-SDK interpolation policy wording (AIC-2695) Co-authored-by: Cursor --- .../com/launchdarkly/sdk/server/ai/internal/Interpolator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d5570679..5b2184c0 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 @@ -10,7 +10,7 @@ /** * Renders AI Config message and instruction templates using Mustache, following the cross-SDK - * interpolation policy shared with the JS and Python SDKs: + * interpolation policy shared with other SDKs: *

    *
  • No HTML escaping. The escape function is the identity, so {@code {{x}}} and * {@code {{{x}}}} render identically and values are emitted verbatim.
  • From 445633f0837a6cc4fe845ffbe6093e3a57242bc4 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 10 Jun 2026 21:45:00 -0400 Subject: [PATCH 04/12] fix: omit kind on nested per-kind ldctx entries for multi-kind contexts (AIC-2695) Match LaunchDarkly's standard context JSON, where per-kind objects under a multi-kind context omit "kind" (it is implied by the property key). Keeps {{ldctx..kind}} consistent with the JS and Python SDKs. Co-authored-by: Cursor --- .../sdk/server/ai/internal/Interpolator.java | 12 ++++++++---- .../sdk/server/ai/internal/InterpolatorTest.java | 11 +++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) 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 5b2184c0..9c6c77b9 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 @@ -101,17 +101,21 @@ private static Map contextToMap(LDContext context) { for (int i = 0; i < count; i++) { LDContext individual = context.getIndividualContext(i); if (individual != null) { - map.put(individual.getKind().toString(), singleContextToMap(individual)); + // 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); + return singleContextToMap(context, true); } - private static Map singleContextToMap(LDContext context) { + private static Map singleContextToMap(LDContext context, boolean includeKind) { Map map = new HashMap<>(); - map.put("kind", context.getKind().toString()); + if (includeKind) { + map.put("kind", context.getKind().toString()); + } map.put("key", context.getKey()); if (context.getName() != null) { map.put("name", context.getName()); diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java index d7800646..315b7a56 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java @@ -85,6 +85,17 @@ public void exposesMultiKindContextByKind() { assertThat(result, is("multi/user-key/Bob/gold")); } + @Test + public void multiKindNestedContextsOmitKind() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(com.launchdarkly.sdk.ContextKind.of("org"), "org-key").build()); + // Standard LaunchDarkly context JSON omits "kind" on the per-kind objects of a multi-kind + // context, so {{ldctx.user.kind}} renders empty rather than echoing the kind name. + assertThat( + interpolator.interpolate("[{{ldctx.user.kind}}]", null, multi), is("[]")); + } + @Test public void ldctxOverridesUserSuppliedValue() { Map userLdctx = new HashMap<>(); From 06e32ba61efbfcae262e9ad87a798b3fdd975562 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 11 Jun 2026 17:13:32 -0400 Subject: [PATCH 05/12] fix: align ldctx anonymous and multi-kind key with other SDKs (AIC-2695) - Always expose ldctx.anonymous as true/false rather than only when true. - Expose the canonical fully-qualified key at the top level of a multi-kind ldctx so {{ldctx.key}} resolves, matching the .NET (and other) SDKs. Adds InterpolatorTest cases for both. Co-authored-by: Cursor --- .../sdk/server/ai/internal/Interpolator.java | 13 ++++++++----- .../server/ai/internal/InterpolatorTest.java | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) 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 9c6c77b9..fd8eb919 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 @@ -87,8 +87,9 @@ private String render(String template, Map 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", : {...}}} - * with one nested map per individual context. + * 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()) { @@ -97,6 +98,9 @@ private static Map contextToMap(LDContext context) { if (context.isMultiple()) { Map map = new HashMap<>(); map.put("kind", "multi"); + // Expose the canonical multi-kind key at the top level (matching other SDKs) so + // {{ldctx.key}} resolves for multi-kind contexts. + map.put("key", context.getFullyQualifiedKey()); int count = context.getIndividualContextCount(); for (int i = 0; i < count; i++) { LDContext individual = context.getIndividualContext(i); @@ -120,9 +124,8 @@ private static Map singleContextToMap(LDContext context, boolean if (context.getName() != null) { map.put("name", context.getName()); } - if (context.isAnonymous()) { - map.put("anonymous", true); - } + // Always expose anonymous as true/false (matching other SDKs) rather than only when true. + 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()) { diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java index 315b7a56..3bc40765 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java @@ -96,6 +96,25 @@ public void multiKindNestedContextsOmitKind() { interpolator.interpolate("[{{ldctx.user.kind}}]", null, multi), is("[]")); } + @Test + public void anonymousIsAlwaysExposedAsBoolean() { + LDContext anon = LDContext.builder("k").anonymous(true).build(); + assertThat(interpolator.interpolate("{{ldctx.anonymous}}", null, anon), is("true")); + // Matches other SDKs: anonymous is emitted even when false, rather than rendering empty. + LDContext named = LDContext.builder("k").build(); + assertThat(interpolator.interpolate("{{ldctx.anonymous}}", null, named), is("false")); + } + + @Test + public void multiKindExposesFullyQualifiedKeyAtTopLevel() { + LDContext multi = LDContext.createMulti( + LDContext.builder("user-key").build(), + LDContext.builder(com.launchdarkly.sdk.ContextKind.of("org"), "org-key").build()); + // {{ldctx.key}} on a multi-kind context resolves to the canonical fully-qualified key. + assertThat( + interpolator.interpolate("{{ldctx.key}}", null, multi), is(multi.getFullyQualifiedKey())); + } + @Test public void ldctxOverridesUserSuppliedValue() { Map userLdctx = new HashMap<>(); From c768a522613616a7da700dfec5c7bbb5cd65bf33 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 11 Jun 2026 17:14:24 -0400 Subject: [PATCH 06/12] chore: drop redundant ldctx comments (AIC-2695) Co-authored-by: Cursor --- .../com/launchdarkly/sdk/server/ai/internal/Interpolator.java | 3 --- 1 file changed, 3 deletions(-) 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 fd8eb919..9fcee28f 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 @@ -98,8 +98,6 @@ private static Map contextToMap(LDContext context) { if (context.isMultiple()) { Map map = new HashMap<>(); map.put("kind", "multi"); - // Expose the canonical multi-kind key at the top level (matching other SDKs) so - // {{ldctx.key}} resolves for multi-kind contexts. map.put("key", context.getFullyQualifiedKey()); int count = context.getIndividualContextCount(); for (int i = 0; i < count; i++) { @@ -124,7 +122,6 @@ private static Map singleContextToMap(LDContext context, boolean if (context.getName() != null) { map.put("name", context.getName()); } - // Always expose anonymous as true/false (matching other SDKs) rather than only when true. 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. From b1b318e3815d95cbe63c074bf89e2758fec8ee75 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 11 Jun 2026 17:25:00 -0400 Subject: [PATCH 07/12] chore: remove obsolete Mustache dependency note from build.gradle (AIC-2695) Co-authored-by: Cursor --- lib/sdk/server-ai/build.gradle | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index 7905dfe2..f8a5117d 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -45,13 +45,6 @@ ext.versions = [ // The *lowest* version of the base SDK we are compatible with. LDClientInterface // appears in this library's public signature, so it is exposed as an `api` dependency. "sdk": "7.14.0" - // NOTE: the Mustache templating engine (for AI Config message/instruction interpolation) is - // intentionally NOT declared as an external dependency. Per the SDK team's supply-chain guidance - // we do not link the external com.samskivert:jmustache artifact; its source has been vendored - // (copied into the relocated internal package com.launchdarkly.sdk.server.ai.internal.mustache) - // so it is compiled from source and ships inside this jar with no third-party runtime dependency. - // See THIRD-PARTY-NOTICES.txt for the upstream license (BSD 3-Clause, Copyright (c) 2010 Michael - // Bayne) and src/.../internal/mustache for the vendored source (AIC-2695). ] ext.libraries = [:] From 31e6032b52fb3e0398556d402ce97a8296c87ea4 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 9 Jun 2026 11:31:47 -0400 Subject: [PATCH 08/12] feat: add AICONF config types & LDAIClient retrieval methods (AIC-2663) Implement the public AI Config types and the LDAIClient retrieval methods that evaluate a flag, validate its mode, interpolate prompt templates, and return a typed config. Config types follow the .NET-style two-hierarchy design (AIConfig / AIConfigDefault bases): AICompletionConfig(+messages), AIAgentConfig (+instructions), AIJudgeConfig(+evaluationMetricKey) results plus parallel *Default builder types (a generic base builder keeps the defaults DRY), and AIAgentConfigRequest for batch agent retrieval. LDAIClientImpl mirrors the JS reference the spec points to: - serialize the caller default to a flag value (with the requested mode) and pass it through jsonValueVariation, so an absent flag yields the default and the eval event records the correct default; - validate mode: a mismatch logs a single warning and returns a disabled config of the requested type (never a config that would NPE the caller); - interpolate messages/instructions via the vendored-Mustache Interpolator, exposing the context as {{ldctx}}; - fire the spec'd usage events ($ld:ai:usage:completion-config, :agent-config, :agent-configs, :judge-config) and emit $ld:ai:sdk:info once at construction, guarded so an uninitialized client can't throw from the constructor. Design decisions (documented in the PR): - Synchronous API (no CompletableFuture): matches the core Java server SDK and avoids Android-API/threading concerns; variation is in-memory post-init so agentConfigs fan-out parallelism buys ~nothing. - No per-field merge of missing fields from the default (matches JS). - Tracking is deferred to Step 4 (AIC-2664): LDAIConfigTracker is a placeholder interface with an internal no-op; configs expose createTracker() so Step 4 fills in behavior without reshaping the public types. Adds LDValueConverter.fromJavaObject (inverse conversion for default serialization). LDAIClientImplTest covers usage events, typed retrieval, interpolation/ldctx, mode-mismatch, default semantics, and agentConfigs. Co-authored-by: Cursor --- lib/sdk/server-ai/README.md | 29 +- .../sdk/server/ai/AIAgentConfig.java | 66 +++ .../sdk/server/ai/AIAgentConfigDefault.java | 132 ++++++ .../sdk/server/ai/AIAgentConfigRequest.java | 107 +++++ .../sdk/server/ai/AICompletionConfig.java | 68 +++ .../server/ai/AICompletionConfigDefault.java | 136 ++++++ .../launchdarkly/sdk/server/ai/AIConfig.java | 105 +++++ .../sdk/server/ai/AIConfigDefault.java | 115 ++++++ .../sdk/server/ai/AIJudgeConfig.java | 54 +++ .../sdk/server/ai/AIJudgeConfigDefault.java | 108 +++++ .../sdk/server/ai/LDAIClient.java | 84 ++++ .../sdk/server/ai/LDAIClientImpl.java | 386 ++++++++++++++++++ .../sdk/server/ai/LDAIConfigTracker.java | 16 + .../sdk/server/ai/internal/AISdkInfo.java | 33 ++ .../server/ai/internal/LDValueConverter.java | 60 +++ .../ai/internal/NoOpAIConfigTracker.java | 19 + .../sdk/server/ai/LDAIClientImplTest.java | 280 +++++++++++++ .../ai/internal/LDValueConverterTest.java | 42 ++ 18 files changed, 1838 insertions(+), 2 deletions(-) create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigRequest.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java diff --git a/lib/sdk/server-ai/README.md b/lib/sdk/server-ai/README.md index a2f2243d..b2d8d4f2 100644 --- a/lib/sdk/server-ai/README.md +++ b/lib/sdk/server-ai/README.md @@ -18,8 +18,33 @@ This library has a minimum Java version of 8. This module is part of the [`java-core`](https://github.com/launchdarkly/java-core) monorepo and is published to Maven Central as `com.launchdarkly:launchdarkly-java-server-sdk-ai`. -Full usage documentation, including AI Config retrieval, tracking, and manual judge evaluation, will be -added as the SDK is built out (see epic AIC-2629). +Construct an `LDAIClient` from an initialized server-side `LDClient`, then retrieve a typed config: + +```java +LDClient ldClient = new LDClient(sdkKey); +LDAIClient aiClient = new LDAIClientImpl(ldClient); + +Map variables = new HashMap<>(); +variables.put("username", "Sandy"); + +AICompletionConfig config = aiClient.completionConfig( + "my-ai-config-key", + context, + AICompletionConfigDefault.disabled(), // fallback when the flag is absent + variables); + +if (config.isEnabled()) { + // config.getModel(), config.getProvider(), and config.getMessages() (already interpolated) + // are ready to pass to your model provider. +} +``` + +The companion `agentConfig`/`agentConfigs` and `judgeConfig` methods retrieve agent and judge +configs respectively. Within a prompt message or agent instruction, the evaluation context is +available as `{{ldctx}}` (for example `{{ldctx.key}}`). + +Metric tracking and manual judge evaluation will be added as the SDK is built out (see epic +AIC-2629). ## Internal API convention diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java new file mode 100644 index 00000000..77d65bee --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java @@ -0,0 +1,66 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A retrieved agent AI Config. This is the result of {@link LDAIClient#agentConfig} (and each entry + * returned by {@link LDAIClient#agentConfigs}). + *

    + * The {@link #getInstructions() instructions} have already had their template interpolated with the + * supplied variables and evaluation context. Instances are immutable. + */ +public final class AIAgentConfig extends AIConfig { + private final String instructions; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + + AIAgentConfig( + String key, + boolean enabled, + ModelConfig model, + ProviderConfig provider, + String instructions, + JudgeConfiguration judgeConfiguration, + Map tools, + Supplier trackerFactory) { + super(key, enabled, AIConfigMode.AGENT, model, provider, trackerFactory); + this.instructions = instructions; + this.judgeConfiguration = judgeConfiguration; + this.tools = tools == null ? null : Collections.unmodifiableMap(tools); + } + + /** + * Returns the interpolated agent instructions. + * + * @return the instructions, or {@code null} if none were specified + */ + public String getInstructions() { + return instructions; + } + + /** + * Returns the judge configuration referencing judges that may evaluate this config. + * + * @return the judge configuration, or {@code null} if none was specified + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the root-level tools map keyed by tool name. + * + * @return an unmodifiable map of tools, or {@code null} if none were specified + */ + public Map getTools() { + return tools; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java new file mode 100644 index 00000000..c87ca565 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java @@ -0,0 +1,132 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A caller-supplied default for {@link LDAIClient#agentConfig} (and {@link LDAIClient#agentConfigs}), + * returned (as an {@link AIAgentConfig}) when the flag is absent or cannot be evaluated. + *

    + * Build instances with {@link #builder()}. Instances are immutable. + */ +public final class AIAgentConfigDefault extends AIConfigDefault { + private final String instructions; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + + private AIAgentConfigDefault(Builder builder) { + super(builder); + this.instructions = builder.instructions; + this.judgeConfiguration = builder.judgeConfiguration; + this.tools = builder.tools == null + ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.tools)); + } + + /** + * Returns the default agent instructions. + * + * @return the instructions, or {@code null} if none were specified + */ + public String getInstructions() { + return instructions; + } + + /** + * Returns the default judge configuration. + * + * @return the judge configuration, or {@code null} if none was specified + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the default root-level tools map. + * + * @return an unmodifiable map of tools, or {@code null} if none were specified + */ + public Map getTools() { + return tools; + } + + /** + * Creates a new builder. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns a disabled default, suitable as a fallback that causes callers to skip the model. + * + * @return a disabled {@link AIAgentConfigDefault} + */ + public static AIAgentConfigDefault disabled() { + return builder().enabled(false).build(); + } + + /** + * Builder for {@link AIAgentConfigDefault}. + */ + public static final class Builder extends AbstractBuilder { + private String instructions; + private JudgeConfiguration judgeConfiguration; + private Map tools; + + private Builder() { + } + + @Override + protected Builder self() { + return this; + } + + /** + * Sets the default agent instructions. + * + * @param instructions the instructions; may be {@code null} + * @return this builder + */ + public Builder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + /** + * Sets the default judge configuration. + * + * @param judgeConfiguration the judge configuration; may be {@code null} + * @return this builder + */ + public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { + this.judgeConfiguration = judgeConfiguration; + return this; + } + + /** + * Sets the default root-level tools map. The map is copied defensively. + * + * @param tools the tools; may be {@code null} + * @return this builder + */ + public Builder tools(Map tools) { + this.tools = tools; + return this; + } + + /** + * Builds the immutable {@link AIAgentConfigDefault}. + * + * @return a new {@link AIAgentConfigDefault} + */ + public AIAgentConfigDefault build() { + return new AIAgentConfigDefault(this); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigRequest.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigRequest.java new file mode 100644 index 00000000..78c43834 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigRequest.java @@ -0,0 +1,107 @@ +package com.launchdarkly.sdk.server.ai; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * A single agent request passed to {@link LDAIClient#agentConfigs}, pairing an agent key with its + * own default and interpolation variables. + *

    + * Build instances with {@link #builder(String)}. Instances are immutable. + */ +public final class AIAgentConfigRequest { + private final String key; + private final AIAgentConfigDefault defaultValue; + private final Map variables; + + private AIAgentConfigRequest(Builder builder) { + this.key = builder.key; + this.defaultValue = builder.defaultValue; + this.variables = builder.variables == null + ? null : Collections.unmodifiableMap(new HashMap<>(builder.variables)); + } + + /** + * Returns the agent key to retrieve. + * + * @return the agent key, never {@code null} + */ + public String getKey() { + return key; + } + + /** + * Returns the default for this agent. + * + * @return the default, or {@code null} if a disabled default should be used + */ + public AIAgentConfigDefault getDefaultValue() { + return defaultValue; + } + + /** + * Returns the interpolation variables for this agent's instructions. + * + * @return an unmodifiable map of variables, or {@code null} if none were specified + */ + public Map getVariables() { + return variables; + } + + /** + * Creates a new builder for a request with the given agent key. + * + * @param key the agent key; must not be {@code null} + * @return a new {@link Builder} + * @throws NullPointerException if {@code key} is {@code null} + */ + public static Builder builder(String key) { + return new Builder(Objects.requireNonNull(key, "key")); + } + + /** + * Builder for {@link AIAgentConfigRequest}. + */ + public static final class Builder { + private final String key; + private AIAgentConfigDefault defaultValue; + private Map variables; + + private Builder(String key) { + this.key = key; + } + + /** + * Sets the default for this agent. + * + * @param defaultValue the default; may be {@code null} + * @return this builder + */ + public Builder defaultValue(AIAgentConfigDefault defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + /** + * Sets the interpolation variables for this agent's instructions. The map is copied defensively. + * + * @param variables the variables; may be {@code null} + * @return this builder + */ + public Builder variables(Map variables) { + this.variables = variables; + return this; + } + + /** + * Builds the immutable {@link AIAgentConfigRequest}. + * + * @return a new {@link AIAgentConfigRequest} + */ + public AIAgentConfigRequest build() { + return new AIAgentConfigRequest(this); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java new file mode 100644 index 00000000..27438977 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java @@ -0,0 +1,68 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A retrieved completion (chat/prompt) AI Config. This is the result of + * {@link LDAIClient#completionConfig}. + *

    + * The {@link #getMessages() messages} have already had their templates interpolated with the + * supplied variables and evaluation context. Instances are immutable. + */ +public final class AICompletionConfig extends AIConfig { + private final List messages; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + + AICompletionConfig( + String key, + boolean enabled, + ModelConfig model, + ProviderConfig provider, + List messages, + JudgeConfiguration judgeConfiguration, + Map tools, + Supplier trackerFactory) { + super(key, enabled, AIConfigMode.COMPLETION, model, provider, trackerFactory); + this.messages = messages == null ? null : Collections.unmodifiableList(messages); + this.judgeConfiguration = judgeConfiguration; + this.tools = tools == null ? null : Collections.unmodifiableMap(tools); + } + + /** + * Returns the interpolated prompt messages. + * + * @return an unmodifiable list of messages, or {@code null} if none were specified + */ + public List getMessages() { + return messages; + } + + /** + * Returns the judge configuration referencing judges that may evaluate this config. + * + * @return the judge configuration, or {@code null} if none was specified + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the root-level tools map keyed by tool name. + * + * @return an unmodifiable map of tools, or {@code null} if none were specified + */ + public Map getTools() { + return tools; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java new file mode 100644 index 00000000..8b11b298 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java @@ -0,0 +1,136 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A caller-supplied default for {@link LDAIClient#completionConfig}, returned (as an + * {@link AICompletionConfig}) when the flag is absent or cannot be evaluated. + *

    + * Build instances with {@link #builder()}. Instances are immutable. + */ +public final class AICompletionConfigDefault extends AIConfigDefault { + private final List messages; + private final JudgeConfiguration judgeConfiguration; + private final Map tools; + + private AICompletionConfigDefault(Builder builder) { + super(builder); + this.messages = builder.messages == null + ? null : Collections.unmodifiableList(new ArrayList<>(builder.messages)); + this.judgeConfiguration = builder.judgeConfiguration; + this.tools = builder.tools == null + ? null : Collections.unmodifiableMap(new LinkedHashMap<>(builder.tools)); + } + + /** + * Returns the default prompt messages. + * + * @return an unmodifiable list of messages, or {@code null} if none were specified + */ + public List getMessages() { + return messages; + } + + /** + * Returns the default judge configuration. + * + * @return the judge configuration, or {@code null} if none was specified + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the default root-level tools map. + * + * @return an unmodifiable map of tools, or {@code null} if none were specified + */ + public Map getTools() { + return tools; + } + + /** + * Creates a new builder. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns a disabled default, suitable as a fallback that causes callers to skip the model. + * + * @return a disabled {@link AICompletionConfigDefault} + */ + public static AICompletionConfigDefault disabled() { + return builder().enabled(false).build(); + } + + /** + * Builder for {@link AICompletionConfigDefault}. + */ + public static final class Builder extends AbstractBuilder { + private List messages; + private JudgeConfiguration judgeConfiguration; + private Map tools; + + private Builder() { + } + + @Override + protected Builder self() { + return this; + } + + /** + * Sets the default prompt messages. The list is copied defensively. + * + * @param messages the messages; may be {@code null} + * @return this builder + */ + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + /** + * Sets the default judge configuration. + * + * @param judgeConfiguration the judge configuration; may be {@code null} + * @return this builder + */ + public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { + this.judgeConfiguration = judgeConfiguration; + return this; + } + + /** + * Sets the default root-level tools map. The map is copied defensively. + * + * @param tools the tools; may be {@code null} + * @return this builder + */ + public Builder tools(Map tools) { + this.tools = tools; + return this; + } + + /** + * Builds the immutable {@link AICompletionConfigDefault}. + * + * @return a new {@link AICompletionConfigDefault} + */ + public AICompletionConfigDefault build() { + return new AICompletionConfigDefault(this); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java new file mode 100644 index 00000000..7b28fb8e --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java @@ -0,0 +1,105 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; + +import java.util.Objects; +import java.util.function.Supplier; + +/** + * The common, mode-independent surface of a retrieved AI Config. + *

    + * Instances are produced by an {@link LDAIClient} retrieval method and are always one of the + * concrete subtypes {@link AICompletionConfig}, {@link AIAgentConfig}, or {@link AIJudgeConfig}. + * They are immutable and safe to share across threads. + *

    + * Application code does not construct these directly; supply defaults via the corresponding + * {@code *Default} types instead. + */ +public abstract class AIConfig { + private final String key; + private final boolean enabled; + private final AIConfigMode mode; + private final ModelConfig model; + private final ProviderConfig provider; + private final Supplier trackerFactory; + + AIConfig( + String key, + boolean enabled, + AIConfigMode mode, + ModelConfig model, + ProviderConfig provider, + Supplier trackerFactory) { + this.key = key; + this.enabled = enabled; + this.mode = mode; + this.model = model; + this.provider = provider; + this.trackerFactory = Objects.requireNonNull(trackerFactory, "trackerFactory"); + } + + /** + * Returns the key of the AI Config that was retrieved. + * + * @return the config key + */ + public String getKey() { + return key; + } + + /** + * Returns whether the retrieved configuration is enabled. + *

    + * When {@code false}, application code should fall back to its own behavior rather than calling a + * model provider; the other fields may be absent. + * + * @return {@code true} if the configuration is enabled + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Returns the mode of this configuration, which matches the concrete config type. + * + * @return the mode, never {@code null} + */ + public AIConfigMode getMode() { + return mode; + } + + /** + * Returns the model configuration. + * + * @return the model, or {@code null} if none was specified + */ + public ModelConfig getModel() { + return model; + } + + /** + * Returns the provider configuration. + * + * @return the provider, or {@code null} if none was specified + */ + public ProviderConfig getProvider() { + return provider; + } + + /** + * Creates a new tracker for a single AI run. + *

    + * Each invocation is intended to create a fresh tracker for one run, so metrics from distinct + * runs are not conflated. Call this once per AI run. + *

    + * In this release the returned tracker is an internal no-op; metric reporting is implemented in a + * later step of the AI SDK. + * + * @return a tracker for this configuration, never {@code null} + */ + public LDAIConfigTracker createTracker() { + return trackerFactory.get(); + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java new file mode 100644 index 00000000..f5e26d86 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java @@ -0,0 +1,115 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; + +/** + * The common, mode-independent surface of a caller-supplied default AI Config. + *

    + * A default is passed to an {@link LDAIClient} retrieval method and is returned (as the + * corresponding concrete config type) when the flag is absent or cannot be evaluated. Concrete + * subtypes are {@link AICompletionConfigDefault}, {@link AIAgentConfigDefault}, and + * {@link AIJudgeConfigDefault}; build them with their {@code builder()} methods. Instances are + * immutable. + */ +public abstract class AIConfigDefault { + private final Boolean enabled; + private final ModelConfig model; + private final ProviderConfig provider; + + AIConfigDefault(AbstractBuilder builder) { + this.enabled = builder.enabled; + this.model = builder.model; + this.provider = builder.provider; + } + + /** + * Returns the configured enabled flag. + * + * @return the enabled flag, or {@code null} if it was not set (treated as disabled) + */ + public Boolean getEnabled() { + return enabled; + } + + /** + * Returns {@code true} only if the enabled flag was explicitly set to {@code true}. + * + * @return whether the default is enabled, defaulting to {@code false} when unset + */ + public boolean isEnabled() { + return Boolean.TRUE.equals(enabled); + } + + /** + * Returns the model configuration. + * + * @return the model, or {@code null} if none was specified + */ + public ModelConfig getModel() { + return model; + } + + /** + * Returns the provider configuration. + * + * @return the provider, or {@code null} if none was specified + */ + public ProviderConfig getProvider() { + return provider; + } + + /** + * Base builder holding the fields shared by every default config type. + *

    + * Uses the curiously recurring generic pattern so that the shared setters return the concrete + * builder subtype for fluent chaining. + * + * @param the concrete builder subtype + */ + protected abstract static class AbstractBuilder> { + private Boolean enabled; + private ModelConfig model; + private ProviderConfig provider; + + /** + * Returns this builder as the concrete subtype. + * + * @return this builder + */ + protected abstract B self(); + + /** + * Sets whether the default configuration is enabled. + * + * @param enabled whether the configuration is enabled + * @return this builder + */ + public B enabled(boolean enabled) { + this.enabled = enabled; + return self(); + } + + /** + * Sets the model configuration. + * + * @param model the model configuration; may be {@code null} + * @return this builder + */ + public B model(ModelConfig model) { + this.model = model; + return self(); + } + + /** + * Sets the provider configuration. + * + * @param provider the provider configuration; may be {@code null} + * @return this builder + */ + public B provider(ProviderConfig provider) { + this.provider = provider; + return self(); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java new file mode 100644 index 00000000..f2e456b1 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java @@ -0,0 +1,54 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; + +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +/** + * A retrieved judge AI Config. This is the result of {@link LDAIClient#judgeConfig}. + *

    + * A judge config evaluates the output of another configuration; it carries the + * {@link #getEvaluationMetricKey() evaluation metric key} it reports against. The + * {@link #getMessages() messages} have already had their templates interpolated. Instances are + * immutable. + */ +public final class AIJudgeConfig extends AIConfig { + private final List messages; + private final String evaluationMetricKey; + + AIJudgeConfig( + String key, + boolean enabled, + ModelConfig model, + ProviderConfig provider, + List messages, + String evaluationMetricKey, + Supplier trackerFactory) { + super(key, enabled, AIConfigMode.JUDGE, model, provider, trackerFactory); + this.messages = messages == null ? null : Collections.unmodifiableList(messages); + this.evaluationMetricKey = evaluationMetricKey; + } + + /** + * Returns the interpolated prompt messages. + * + * @return an unmodifiable list of messages, or {@code null} if none were specified + */ + public List getMessages() { + return messages; + } + + /** + * Returns the metric key this judge reports against. + * + * @return the evaluation metric key, or {@code null} if none was resolved + */ + public String getEvaluationMetricKey() { + return evaluationMetricKey; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java new file mode 100644 index 00000000..5c12fff7 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java @@ -0,0 +1,108 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A caller-supplied default for {@link LDAIClient#judgeConfig}, returned (as an + * {@link AIJudgeConfig}) when the flag is absent or cannot be evaluated. + *

    + * Build instances with {@link #builder()}. Instances are immutable. + */ +public final class AIJudgeConfigDefault extends AIConfigDefault { + private final List messages; + private final String evaluationMetricKey; + + private AIJudgeConfigDefault(Builder builder) { + super(builder); + this.messages = builder.messages == null + ? null : Collections.unmodifiableList(new ArrayList<>(builder.messages)); + this.evaluationMetricKey = builder.evaluationMetricKey; + } + + /** + * Returns the default prompt messages. + * + * @return an unmodifiable list of messages, or {@code null} if none were specified + */ + public List getMessages() { + return messages; + } + + /** + * Returns the default evaluation metric key. + * + * @return the evaluation metric key, or {@code null} if none was specified + */ + public String getEvaluationMetricKey() { + return evaluationMetricKey; + } + + /** + * Creates a new builder. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns a disabled default, suitable as a fallback that causes callers to skip the model. + * + * @return a disabled {@link AIJudgeConfigDefault} + */ + public static AIJudgeConfigDefault disabled() { + return builder().enabled(false).build(); + } + + /** + * Builder for {@link AIJudgeConfigDefault}. + */ + public static final class Builder extends AbstractBuilder { + private List messages; + private String evaluationMetricKey; + + private Builder() { + } + + @Override + protected Builder self() { + return this; + } + + /** + * Sets the default prompt messages. The list is copied defensively. + * + * @param messages the messages; may be {@code null} + * @return this builder + */ + public Builder messages(List messages) { + this.messages = messages; + return this; + } + + /** + * Sets the default evaluation metric key. + * + * @param evaluationMetricKey the evaluation metric key; may be {@code null} + * @return this builder + */ + public Builder evaluationMetricKey(String evaluationMetricKey) { + this.evaluationMetricKey = evaluationMetricKey; + return this; + } + + /** + * Builds the immutable {@link AIJudgeConfigDefault}. + * + * @return a new {@link AIJudgeConfigDefault} + */ + public AIJudgeConfigDefault build() { + return new AIJudgeConfigDefault(this); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java new file mode 100644 index 00000000..16e38e06 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java @@ -0,0 +1,84 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.LDContext; + +import java.util.List; +import java.util.Map; + +/** + * The LaunchDarkly Server-Side AI client, for retrieving AI Configs. + *

    + * An {@code LDAIClient} wraps an initialized server-side {@code LDClient}. Each retrieval method + * evaluates the AI Config flag for the given key and context, validates that the variation's mode + * matches the requested kind, interpolates any prompt messages or instructions with the supplied + * variables (and the evaluation context, exposed to templates as {@code ldctx}), and returns a + * strongly-typed config. + *

    + * When the flag is absent or cannot be evaluated, the caller-supplied default is returned as the + * corresponding config type. When the variation's mode does not match the requested kind, a + * disabled config of the requested type is returned and a warning is logged; a config is never + * returned in a state that would force the caller into a {@code NullPointerException}. + *

    + * Implementations are thread-safe. + */ +public interface LDAIClient { + /** + * Retrieves a completion (chat/prompt) AI Config. + * + * @param key the AI Config key + * @param context the context to evaluate the configuration in + * @param defaultValue the default returned when the flag is absent or cannot be evaluated; when + * {@code null}, a disabled default is used + * @param variables variables interpolated into the prompt messages; may be {@code null} + * @return the completion config, never {@code null} + */ + AICompletionConfig completionConfig( + String key, + LDContext context, + AICompletionConfigDefault defaultValue, + Map variables); + + /** + * Retrieves a single agent AI Config. + * + * @param key the AI Config key + * @param context the context to evaluate the configuration in + * @param defaultValue the default returned when the flag is absent or cannot be evaluated; when + * {@code null}, a disabled default is used + * @param variables variables interpolated into the agent instructions; may be {@code null} + * @return the agent config, never {@code null} + */ + AIAgentConfig agentConfig( + String key, + LDContext context, + AIAgentConfigDefault defaultValue, + Map variables); + + /** + * Retrieves multiple agent AI Configs in a single call. + *

    + * Each request carries its own key, default, and interpolation variables. The returned map is + * keyed by agent key and preserves the order of the requests. + * + * @param agentConfigs the agent requests to retrieve + * @param context the context to evaluate the configurations in + * @return a map of agent key to its retrieved {@link AIAgentConfig}, never {@code null} + */ + Map agentConfigs(List agentConfigs, LDContext context); + + /** + * Retrieves a judge AI Config. + * + * @param key the AI Config key + * @param context the context to evaluate the configuration in + * @param defaultValue the default returned when the flag is absent or cannot be evaluated; when + * {@code null}, a disabled default is used + * @param variables variables interpolated into the prompt messages; may be {@code null} + * @return the judge config, never {@code null} + */ + AIJudgeConfig judgeConfig( + String key, + LDContext context, + AIJudgeConfigDefault defaultValue, + Map variables); +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java new file mode 100644 index 00000000..792b42f1 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java @@ -0,0 +1,386 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LDSLF4J; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.ArrayBuilder; +import com.launchdarkly.sdk.ContextKind; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; +import com.launchdarkly.sdk.server.ai.internal.AIConfigFlagValue; +import com.launchdarkly.sdk.server.ai.internal.AIConfigParser; +import com.launchdarkly.sdk.server.ai.internal.AISdkInfo; +import com.launchdarkly.sdk.server.ai.internal.Interpolator; +import com.launchdarkly.sdk.server.ai.internal.LDValueConverter; +import com.launchdarkly.sdk.server.ai.internal.NoOpAIConfigTracker; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * The default {@link LDAIClient} implementation, backed by an initialized server-side + * {@code LDClient}. + *

    + * Construct one per application alongside your {@code LDClient}: + *

    {@code
    + * LDClient ldClient = new LDClient(sdkKey);
    + * LDAIClient aiClient = new LDAIClientImpl(ldClient);
    + * }
    + *

    + * This class is thread-safe. It holds only the immutable, thread-safe base client, a logger, and a + * single shared {@link Interpolator} (whose compiled-template cache is itself thread-safe); every + * config it returns is immutable. + */ +public final class LDAIClientImpl implements LDAIClient { + private static final String TRACK_SDK_INFO = "$ld:ai:sdk:info"; + private static final String TRACK_USAGE_COMPLETION_CONFIG = "$ld:ai:usage:completion-config"; + private static final String TRACK_USAGE_AGENT_CONFIG = "$ld:ai:usage:agent-config"; + private static final String TRACK_USAGE_AGENT_CONFIGS = "$ld:ai:usage:agent-configs"; + private static final String TRACK_USAGE_JUDGE_CONFIG = "$ld:ai:usage:judge-config"; + + private static final LDContext INIT_TRACK_CONTEXT = LDContext + .builder("ld-internal-tracking") + .kind(ContextKind.of("ld_ai")) + .anonymous(true) + .build(); + + // Tracking is implemented in a later step; until then every config hands out the no-op tracker. + private static final Supplier TRACKER_FACTORY = () -> NoOpAIConfigTracker.INSTANCE; + + private final LDClientInterface client; + private final LDLogger logger; + private final Interpolator interpolator; + + /** + * Creates an AI client wrapping the given base client, using a default logger. + * + * @param client an initialized server-side {@code LDClient}; must not be {@code null} + */ + public LDAIClientImpl(LDClientInterface client) { + this(client, defaultLogger()); + } + + /** + * Creates an AI client wrapping the given base client and logging through the given logger. + * + * @param client an initialized server-side {@code LDClient}; must not be {@code null} + * @param logger the logger to use for warnings; must not be {@code null} + */ + public LDAIClientImpl(LDClientInterface client, LDLogger logger) { + this.client = Objects.requireNonNull(client, "client"); + this.logger = Objects.requireNonNull(logger, "logger"); + this.interpolator = new Interpolator(); + + // Report SDK info once. Guard it: if the base client is not yet fully initialized, a track call + // must never propagate an exception out of this constructor. + try { + LDValue info = LDValue.buildObject() + .put("aiSdkName", AISdkInfo.NAME) + .put("aiSdkVersion", AISdkInfo.VERSION) + .put("aiSdkLanguage", AISdkInfo.LANGUAGE) + .build(); + client.trackMetric(TRACK_SDK_INFO, INIT_TRACK_CONTEXT, info, 1); + } catch (Exception e) { + this.logger.warn("Unable to record AI SDK info event: {}", e.toString()); + } + } + + @Override + public AICompletionConfig completionConfig( + String key, + LDContext context, + AICompletionConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1); + AICompletionConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled(); + return (AICompletionConfig) evaluate(key, context, effectiveDefault, AIConfigMode.COMPLETION, variables); + } + + @Override + public AIAgentConfig agentConfig( + String key, + LDContext context, + AIAgentConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_AGENT_CONFIG, context, LDValue.of(key), 1); + return evaluateAgent(key, context, defaultValue, variables); + } + + @Override + public Map agentConfigs( + List agentConfigs, LDContext context) { + int count = agentConfigs == null ? 0 : agentConfigs.size(); + client.trackMetric(TRACK_USAGE_AGENT_CONFIGS, context, LDValue.of(count), count); + + Map result = new LinkedHashMap<>(); + if (agentConfigs != null) { + for (AIAgentConfigRequest request : agentConfigs) { + if (request == null) { + continue; + } + result.put( + request.getKey(), + evaluateAgent(request.getKey(), context, request.getDefaultValue(), request.getVariables())); + } + } + return result; + } + + @Override + public AIJudgeConfig judgeConfig( + String key, + LDContext context, + AIJudgeConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_JUDGE_CONFIG, context, LDValue.of(key), 1); + AIJudgeConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled(); + return (AIJudgeConfig) evaluate(key, context, effectiveDefault, AIConfigMode.JUDGE, variables); + } + + private AIAgentConfig evaluateAgent( + String key, LDContext context, AIAgentConfigDefault defaultValue, Map variables) { + AIAgentConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled(); + return (AIAgentConfig) evaluate(key, context, effectiveDefault, AIConfigMode.AGENT, variables); + } + + /** + * Core evaluation: render the default as a flag value (so the base SDK returns it verbatim when + * the flag is absent), evaluate, validate the mode, and build the typed config with interpolated + * prompt content. + */ + private AIConfig evaluate( + String key, + LDContext context, + AIConfigDefault defaultValue, + AIConfigMode mode, + Map variables) { + LDValue defaultFlagValue = toFlagValue(defaultValue, mode); + LDValue value = client.jsonValueVariation(key, context, defaultFlagValue); + AIConfigFlagValue parsed = AIConfigParser.parse(value); + + AIConfigMode flagMode = parsed.getMode() != null ? parsed.getMode() : AIConfigMode.COMPLETION; + if (flagMode != mode) { + logger.warn( + "AI Config mode mismatch for {}: expected {}, got {}. Returning disabled config.", + key, mode.getWireValue(), flagMode.getWireValue()); + return disabledConfig(key, mode); + } + + return buildConfig(key, mode, parsed, context, variables); + } + + private AIConfig buildConfig( + String key, + AIConfigMode mode, + AIConfigFlagValue parsed, + LDContext context, + Map variables) { + switch (mode) { + case AGENT: + return new AIAgentConfig( + key, + parsed.isEnabled(), + parsed.getModel(), + parsed.getProvider(), + interpolate(parsed.getInstructions(), variables, context), + parsed.getJudgeConfiguration(), + parsed.getTools(), + TRACKER_FACTORY); + case JUDGE: + return new AIJudgeConfig( + key, + parsed.isEnabled(), + parsed.getModel(), + parsed.getProvider(), + interpolateMessages(parsed.getMessages(), variables, context), + parsed.getEvaluationMetricKey(), + TRACKER_FACTORY); + case COMPLETION: + default: + return new AICompletionConfig( + key, + parsed.isEnabled(), + parsed.getModel(), + parsed.getProvider(), + interpolateMessages(parsed.getMessages(), variables, context), + parsed.getJudgeConfiguration(), + parsed.getTools(), + TRACKER_FACTORY); + } + } + + private AIConfig disabledConfig(String key, AIConfigMode mode) { + switch (mode) { + case AGENT: + return new AIAgentConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); + case JUDGE: + return new AIJudgeConfig(key, false, null, null, null, null, TRACKER_FACTORY); + case COMPLETION: + default: + return new AICompletionConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); + } + } + + private List interpolateMessages( + List messages, Map variables, LDContext context) { + if (messages == null) { + return null; + } + List result = new ArrayList<>(messages.size()); + for (LDMessage message : messages) { + result.add(message.withContent(interpolator.interpolate(message.getContent(), variables, context))); + } + return result; + } + + private String interpolate(String template, Map variables, LDContext context) { + return interpolator.interpolate(template, variables, context); + } + + // --------------------------------------------------------------------------- + // Default -> flag value rendering (inverse of AIConfigParser). Kept in sync with the field names + // the parser reads so a default round-trips back to an equivalent config. + // --------------------------------------------------------------------------- + + private static LDValue toFlagValue(AIConfigDefault config, AIConfigMode mode) { + ObjectBuilder builder = LDValue.buildObject(); + builder.put("_ldMeta", LDValue.buildObject() + .put("enabled", config.isEnabled()) + .put("mode", mode.getWireValue()) + .build()); + + if (config.getModel() != null) { + builder.put("model", modelToLdValue(config.getModel())); + } + if (config.getProvider() != null) { + builder.put("provider", providerToLdValue(config.getProvider())); + } + + if (config instanceof AICompletionConfigDefault) { + AICompletionConfigDefault completion = (AICompletionConfigDefault) config; + putMessages(builder, completion.getMessages()); + putJudgeConfiguration(builder, completion.getJudgeConfiguration()); + putTools(builder, completion.getTools()); + } else if (config instanceof AIAgentConfigDefault) { + AIAgentConfigDefault agent = (AIAgentConfigDefault) config; + if (agent.getInstructions() != null) { + builder.put("instructions", agent.getInstructions()); + } + putJudgeConfiguration(builder, agent.getJudgeConfiguration()); + putTools(builder, agent.getTools()); + } else if (config instanceof AIJudgeConfigDefault) { + AIJudgeConfigDefault judge = (AIJudgeConfigDefault) config; + putMessages(builder, judge.getMessages()); + if (judge.getEvaluationMetricKey() != null) { + builder.put("evaluationMetricKey", judge.getEvaluationMetricKey()); + } + } + + return builder.build(); + } + + private static LDValue modelToLdValue(ModelConfig model) { + ObjectBuilder builder = LDValue.buildObject(); + if (model.getName() != null) { + builder.put("name", model.getName()); + } + if (!model.getParameters().isEmpty()) { + builder.put("parameters", LDValueConverter.fromJavaObject(model.getParameters())); + } + if (!model.getCustom().isEmpty()) { + builder.put("custom", LDValueConverter.fromJavaObject(model.getCustom())); + } + return builder.build(); + } + + private static LDValue providerToLdValue(ProviderConfig provider) { + ObjectBuilder builder = LDValue.buildObject(); + if (provider.getName() != null) { + builder.put("name", provider.getName()); + } + return builder.build(); + } + + private static void putMessages(ObjectBuilder builder, List messages) { + if (messages == null) { + return; + } + ArrayBuilder array = LDValue.buildArray(); + for (LDMessage message : messages) { + array.add(LDValue.buildObject() + .put("role", message.getRole().getWireValue()) + .put("content", message.getContent()) + .build()); + } + builder.put("messages", array.build()); + } + + private static void putJudgeConfiguration(ObjectBuilder builder, JudgeConfiguration judgeConfiguration) { + if (judgeConfiguration == null) { + return; + } + ArrayBuilder judges = LDValue.buildArray(); + for (JudgeConfiguration.Judge judge : judgeConfiguration.getJudges()) { + judges.add(LDValue.buildObject() + .put("key", judge.getKey()) + .put("samplingRate", judge.getSamplingRate()) + .build()); + } + builder.put("judgeConfiguration", LDValue.buildObject().put("judges", judges.build()).build()); + } + + private static void putTools(ObjectBuilder builder, Map tools) { + if (tools == null) { + return; + } + ObjectBuilder toolsObject = LDValue.buildObject(); + for (Map.Entry entry : tools.entrySet()) { + ToolConfig tool = entry.getValue(); + ObjectBuilder toolObject = LDValue.buildObject(); + if (tool.getName() != null) { + toolObject.put("name", tool.getName()); + } + if (tool.getDescription() != null) { + toolObject.put("description", tool.getDescription()); + } + if (tool.getType() != null) { + toolObject.put("type", tool.getType()); + } + if (!tool.getParameters().isEmpty()) { + toolObject.put("parameters", LDValueConverter.fromJavaObject(tool.getParameters())); + } + if (!tool.getCustomParameters().isEmpty()) { + toolObject.put("customParameters", LDValueConverter.fromJavaObject(tool.getCustomParameters())); + } + toolsObject.put(entry.getKey(), toolObject.build()); + } + builder.put("tools", toolsObject.build()); + } + + private static LDLogger defaultLogger() { + LDLogAdapter adapter; + try { + Class.forName("org.slf4j.LoggerFactory"); + adapter = LDSLF4J.adapter(); + } catch (ClassNotFoundException e) { + adapter = Logs.toConsole(); + } + return LDLogger.withAdapter(adapter, "LaunchDarkly.AI"); + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java new file mode 100644 index 00000000..a298e33b --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java @@ -0,0 +1,16 @@ +package com.launchdarkly.sdk.server.ai; + +/** + * Reports events related to a single AI run of an {@link AIConfig}. + *

    + * A tracker is obtained from a retrieved config via {@link AIConfig#createTracker()}. Each tracker + * corresponds to one AI run and is used to record metrics such as model usage, duration, and + * feedback against the AI Config it was created from. + *

    + * This interface is an intentional placeholder. The metric- and feedback-reporting + * methods (and resumption-token support) are introduced in a later step of the AI SDK build-out; it + * is defined here so that the public config types expose a stable {@code createTracker()} surface. + * The only implementation in this release is an internal no-op. + */ +public interface LDAIConfigTracker { +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java new file mode 100644 index 00000000..42cdee10 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java @@ -0,0 +1,33 @@ +package com.launchdarkly.sdk.server.ai.internal; + +/** + * Identifying information about this AI SDK, reported once per client via the + * {@code $ld:ai:sdk:info} event. + *

    + * This class is an internal implementation detail and is not part of the supported API. + */ +public final class AISdkInfo { + /** + * The published artifact name of this SDK. + */ + public static final String NAME = "launchdarkly-java-server-sdk-ai"; + + /** + * The implementation language reported to LaunchDarkly. + */ + public static final String LANGUAGE = "java"; + + /** + * The SDK version. + *

    + * This must be kept in step with the {@code version} in {@code gradle.properties} (which + * {@code release-please} updates on release). It is a plain constant rather than a manifest + * lookup so that it resolves correctly in unit tests and when the classes are used outside the + * packaged jar. + */ + // x-release-please-version + public static final String VERSION = "0.1.0"; + + private AISdkInfo() { + } +} 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 index 249b2eed..48d3ee84 100644 --- 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 @@ -1,7 +1,9 @@ package com.launchdarkly.sdk.server.ai.internal; +import com.launchdarkly.sdk.ArrayBuilder; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; import java.util.ArrayList; import java.util.Collections; @@ -69,6 +71,64 @@ public static Map toMap(LDValue value) { return null; } + /** + * Converts a plain Java value back into an {@link LDValue}. + *

    + * This is the inverse of {@link #toJavaObject(LDValue)} and is used to render a caller-supplied + * default config back into the JSON flag-value shape so it can flow through the base SDK's + * variation call. Supported inputs are {@link LDValue}, {@link String}, {@link Boolean}, + * {@link Number}, {@link Map} (with string keys), and {@link Iterable}; any other type (or a + * {@code null}) becomes {@link LDValue#ofNull()}. Conversion depth is capped (see + * {@link #MAX_DEPTH}); values nested more deeply are dropped to {@code null}. + * + * @param value the value to convert; may be {@code null} + * @return the equivalent {@link LDValue}, never {@code null} + */ + public static LDValue fromJavaObject(Object value) { + return fromJavaObject(value, 0); + } + + private static LDValue fromJavaObject(Object value, int depth) { + if (value == null || depth >= MAX_DEPTH) { + return LDValue.ofNull(); + } + if (value instanceof LDValue) { + return (LDValue) value; + } + if (value instanceof String) { + return LDValue.of((String) value); + } + if (value instanceof Boolean) { + return LDValue.of((Boolean) value); + } + if (value instanceof Integer) { + return LDValue.of((Integer) value); + } + if (value instanceof Long) { + return LDValue.of((Long) value); + } + if (value instanceof Number) { + return LDValue.of(((Number) value).doubleValue()); + } + if (value instanceof Map) { + ObjectBuilder builder = LDValue.buildObject(); + for (Map.Entry entry : ((Map) value).entrySet()) { + if (entry.getKey() != null) { + builder.put(entry.getKey().toString(), fromJavaObject(entry.getValue(), depth + 1)); + } + } + return builder.build(); + } + if (value instanceof Iterable) { + ArrayBuilder builder = LDValue.buildArray(); + for (Object element : (Iterable) value) { + builder.add(fromJavaObject(element, depth + 1)); + } + return builder.build(); + } + return LDValue.ofNull(); + } + private static Object convert(LDValue value, int depth) { if (value == null || value.isNull()) { return null; diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java new file mode 100644 index 00000000..1cbc3c51 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.launchdarkly.sdk.server.ai.LDAIConfigTracker; + +/** + * The no-op {@link LDAIConfigTracker} used until metric reporting is implemented in a later step of + * the AI SDK. It is immutable and stateless, so a single shared instance is safe to reuse. + *

    + * This class is an internal implementation detail and is not part of the supported API. + */ +public final class NoOpAIConfigTracker implements LDAIConfigTracker { + /** + * The shared instance. + */ + public static final NoOpAIConfigTracker INSTANCE = new NoOpAIConfigTracker(); + + private NoOpAIConfigTracker() { + } +} diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java new file mode 100644 index 00000000..2ebdf8ed --- /dev/null +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -0,0 +1,280 @@ +package com.launchdarkly.sdk.server.ai; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class LDAIClientImplTest { + private LDClientInterface client; + private LogCapture logCapture; + private LDLogger logger; + private LDAIClientImpl ai; + private final LDContext context = LDContext.create("user-key"); + + @Before + public void setUp() { + client = mock(LDClientInterface.class); + logCapture = Logs.capture(); + logger = LDLogger.withAdapter(logCapture, "test"); + ai = new LDAIClientImpl(client, logger); + } + + private List warnings() { + return logCapture.getMessages().stream() + .filter(m -> m.getLevel() == LDLogLevel.WARN) + .map(LogCapture.Message::getText) + .collect(Collectors.toList()); + } + + // ---- SDK info ------------------------------------------------------------- + + @Test + public void constructorEmitsSdkInfoEvent() { + LDValue expected = LDValue.buildObject() + .put("aiSdkName", "launchdarkly-java-server-sdk-ai") + .put("aiSdkVersion", "0.1.0") + .put("aiSdkLanguage", "java") + .build(); + verify(client).trackMetric(eq("$ld:ai:sdk:info"), any(LDContext.class), eq(expected), eq(1.0)); + } + + @Test + public void constructorDoesNotThrowWhenSdkInfoTrackingFails() { + LDClientInterface throwingClient = mock(LDClientInterface.class); + org.mockito.Mockito.doThrow(new RuntimeException("not initialized")) + .when(throwingClient).trackMetric(eq("$ld:ai:sdk:info"), any(), any(), eq(1.0)); + // Must not propagate out of the constructor. + new LDAIClientImpl(throwingClient, logger); + } + + // ---- Usage events --------------------------------------------------------- + + @Test + public void completionConfigFiresUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.completionConfig("my-key", context, null, null); + verify(client).trackMetric(eq("$ld:ai:usage:completion-config"), eq(context), eq(LDValue.of("my-key")), eq(1.0)); + } + + @Test + public void agentConfigFiresUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.agentConfig("agent-key", context, null, null); + verify(client).trackMetric(eq("$ld:ai:usage:agent-config"), eq(context), eq(LDValue.of("agent-key")), eq(1.0)); + } + + @Test + public void judgeConfigFiresUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.judgeConfig("judge-key", context, null, null); + verify(client).trackMetric(eq("$ld:ai:usage:judge-config"), eq(context), eq(LDValue.of("judge-key")), eq(1.0)); + } + + @Test + public void agentConfigsFiresUsageEventWithCount() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + List requests = Arrays.asList( + AIAgentConfigRequest.builder("a").build(), + AIAgentConfigRequest.builder("b").build()); + ai.agentConfigs(requests, context); + verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(2)), eq(2.0)); + } + + // ---- Typed retrieval + interpolation ------------------------------------- + + @Test + public void completionConfigReturnsTypedConfigFromVariation() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"model\":{\"name\":\"gpt-4\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"Hello {{name}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + Map variables = new HashMap<>(); + variables.put("name", "World"); + AICompletionConfig config = ai.completionConfig("key", context, null, variables); + + assertThat(config, is(notNullValue())); + assertThat(config.getKey(), is("key")); + assertThat(config.isEnabled(), is(true)); + assertThat(config.getMode(), is(AIConfigMode.COMPLETION)); + assertThat(config.getModel().getName(), is("gpt-4")); + assertThat(config.getMessages(), hasSize(1)); + assertThat(config.getMessages().get(0).getContent(), is("Hello World")); + assertThat(config.getMessages().get(0).getRole(), is(LDMessage.Role.SYSTEM)); + } + + @Test + public void interpolationExposesContextAsLdctx() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"{{ldctx.key}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfig config = ai.completionConfig("key", LDContext.create("ctx-123"), null, null); + assertThat(config.getMessages().get(0).getContent(), is("ctx-123")); + } + + @Test + public void agentConfigInterpolatesInstructions() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"You research {{topic}}\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + Map variables = new HashMap<>(); + variables.put("topic", "climate"); + AIAgentConfig config = ai.agentConfig("key", context, null, variables); + + assertThat(config.getMode(), is(AIConfigMode.AGENT)); + assertThat(config.getInstructions(), is("You research climate")); + } + + @Test + public void judgeConfigResolvesFirstNonBlankEvaluationMetricKey() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"}," + + "\"evaluationMetricKeys\":[\" \",\"\",\"relevance\",\"other\"]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIJudgeConfig config = ai.judgeConfig("key", context, null, null); + assertThat(config.getMode(), is(AIConfigMode.JUDGE)); + assertThat(config.getEvaluationMetricKey(), is("relevance")); + } + + // ---- Mode validation ------------------------------------------------------ + + @Test + public void modeMismatchReturnsDisabledConfigAndWarnsOnce() { + String agentJson = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"hi\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(agentJson)); + + // Requesting a completion config against an agent-mode flag. + AICompletionConfig config = ai.completionConfig("key", context, null, null); + + assertThat(config, is(notNullValue())); + assertThat(config.isEnabled(), is(false)); + assertThat(config.getMessages(), is(nullValue())); + assertThat(config.getMode(), is(AIConfigMode.COMPLETION)); + assertThat(warnings(), hasSize(1)); + assertThat(warnings().get(0), containsString("mode mismatch")); + } + + @Test + public void matchingModeDoesNotWarn() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + ai.completionConfig("key", context, null, null); + assertThat(warnings(), is(empty())); + } + + // ---- Default semantics ---------------------------------------------------- + + @Test + public void absentFlagReturnsConfiguredDefault() { + // Simulate an absent flag: the base SDK echoes back the default value we passed in. + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(true) + .model(ModelConfig.builder("default-model").build()) + .messages(Arrays.asList(new LDMessage(LDMessage.Role.SYSTEM, "default {{x}}"))) + .build(); + + Map variables = new HashMap<>(); + variables.put("x", "value"); + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables); + + assertThat(config.isEnabled(), is(true)); + assertThat(config.getModel().getName(), is("default-model")); + assertThat(config.getMessages().get(0).getContent(), is("default value")); + } + + @Test + public void nullDefaultYieldsDisabledConfigWhenAbsent() { + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + AICompletionConfig config = ai.completionConfig("key", context, null, null); + assertThat(config.isEnabled(), is(false)); + } + + @Test + public void doesNotMergeMissingFieldsFromDefault() { + // The flag is present and enabled but omits messages; the default supplies messages. + // Per the JS-aligned semantics, the result reflects the variation as-is (no per-field merge), + // so messages remain absent. + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},\"model\":{\"name\":\"flag-model\"}}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(false) + .messages(Arrays.asList(new LDMessage(LDMessage.Role.SYSTEM, "should not appear"))) + .build(); + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables(/* none */)); + + assertThat(config.getModel().getName(), is("flag-model")); + assertThat(config.getMessages(), is(nullValue())); + } + + // ---- agentConfigs --------------------------------------------------------- + + @Test + public void agentConfigsReturnsMapKeyedByRequestPreservingOrder() { + when(client.jsonValueVariation(anyString(), any(), any())).thenAnswer(inv -> { + String key = inv.getArgument(0); + return LDValue.parse("{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"I am " + key + "\"}"); + }); + + List requests = Arrays.asList( + AIAgentConfigRequest.builder("research").build(), + AIAgentConfigRequest.builder("writing").build()); + Map agents = ai.agentConfigs(requests, context); + + assertThat(new ArrayList<>(agents.keySet()), contains("research", "writing")); + assertThat(agents.get("research").getInstructions(), is("I am research")); + assertThat(agents.get("writing").getInstructions(), is("I am writing")); + } + + @Test + public void agentConfigsHandlesEmptyList() { + Map agents = ai.agentConfigs(new ArrayList<>(), context); + assertThat(agents.entrySet(), is(empty())); + verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(0)), eq(0.0)); + } + + private static Map variables() { + return new HashMap<>(); + } +} 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 index 17872c97..769d2a88 100644 --- 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 @@ -7,6 +7,9 @@ import com.launchdarkly.sdk.LDValue; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -70,4 +73,43 @@ public void deeplyNestedInputDoesNotOverflowAndIsCapped() { Object converted = LDValueConverter.toJavaObject(LDValue.parse(json.toString())); assertThat(converted, instanceOf(List.class)); } + + @Test + public void fromJavaObjectConvertsScalars() { + assertThat(LDValueConverter.fromJavaObject(null), is(LDValue.ofNull())); + assertThat(LDValueConverter.fromJavaObject("hi"), is(LDValue.of("hi"))); + assertThat(LDValueConverter.fromJavaObject(Boolean.TRUE), is(LDValue.of(true))); + assertThat(LDValueConverter.fromJavaObject(7), is(LDValue.of(7))); + assertThat(LDValueConverter.fromJavaObject(7L), is(LDValue.of(7L))); + assertThat(LDValueConverter.fromJavaObject(0.5), is(LDValue.of(0.5))); + } + + @Test + public void fromJavaObjectConvertsNestedMapsAndLists() { + Map input = new LinkedHashMap<>(); + input.put("a", 1L); + input.put("b", Arrays.asList("x", 2L)); + Map nested = new LinkedHashMap<>(); + nested.put("d", true); + input.put("c", nested); + + LDValue value = LDValueConverter.fromJavaObject(input); + assertThat(value, is(LDValue.parse("{\"a\":1,\"b\":[\"x\",2],\"c\":{\"d\":true}}"))); + } + + @Test + public void fromJavaObjectRoundTrips() { + LDValue original = LDValue.parse("{\"name\":\"gpt-4\",\"n\":3,\"f\":0.25,\"on\":true,\"list\":[1,2]}"); + Object asJava = LDValueConverter.toJavaObject(original); + assertThat(LDValueConverter.fromJavaObject(asJava), is(original)); + } + + @Test + public void fromJavaObjectDropsUnsupportedTypes() { + // An unsupported value type becomes JSON null rather than throwing. + assertThat(LDValueConverter.fromJavaObject(new Object()), is(LDValue.ofNull())); + List list = new ArrayList<>(); + list.add(new Object()); + assertThat(LDValueConverter.fromJavaObject(list), is(LDValue.parse("[null]"))); + } } From d5a4ee596c186bfa66cf0b3411cb24c1e9418503 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 10 Jun 2026 19:42:31 -0400 Subject: [PATCH 09/12] refactor: return typed default directly instead of JSON round-trip (AIC-2663) When a flag is absent or unevaluable, build the typed AIConfig straight from the caller's default rather than serializing the default to LDValue and parsing it back. Drops the now-unused LDValueConverter.fromJavaObject helpers. Co-authored-by: Cursor --- .../sdk/server/ai/LDAIClientImpl.java | 195 ++++++------------ .../server/ai/internal/LDValueConverter.java | 60 ------ .../sdk/server/ai/LDAIClientImplTest.java | 3 +- .../ai/internal/LDValueConverterTest.java | 42 ---- 4 files changed, 65 insertions(+), 235 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java index 792b42f1..ae0aa6b5 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java @@ -4,22 +4,16 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LDSLF4J; import com.launchdarkly.logging.Logs; -import com.launchdarkly.sdk.ArrayBuilder; import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; import com.launchdarkly.sdk.server.ai.internal.AIConfigFlagValue; import com.launchdarkly.sdk.server.ai.internal.AIConfigParser; import com.launchdarkly.sdk.server.ai.internal.AISdkInfo; import com.launchdarkly.sdk.server.ai.internal.Interpolator; -import com.launchdarkly.sdk.server.ai.internal.LDValueConverter; import com.launchdarkly.sdk.server.ai.internal.NoOpAIConfigTracker; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; @@ -160,9 +154,9 @@ private AIAgentConfig evaluateAgent( } /** - * Core evaluation: render the default as a flag value (so the base SDK returns it verbatim when - * the flag is absent), evaluate, validate the mode, and build the typed config with interpolated - * prompt content. + * Core evaluation: evaluate the flag with a null sentinel default, validate the mode, and build + * the typed config with interpolated prompt content. When the flag is absent or cannot be + * evaluated, the caller's typed default is returned directly (no JSON round-trip). */ private AIConfig evaluate( String key, @@ -170,8 +164,15 @@ private AIConfig evaluate( AIConfigDefault defaultValue, AIConfigMode mode, Map variables) { - LDValue defaultFlagValue = toFlagValue(defaultValue, mode); - LDValue value = client.jsonValueVariation(key, context, defaultFlagValue); + LDValue value = client.jsonValueVariation(key, context, LDValue.ofNull()); + + // A valid AI Config variation is always a JSON object (it carries the _ldMeta block). When the + // flag is absent or cannot be evaluated the base SDK hands back our null sentinel; in that case + // we return the caller's typed default directly rather than serializing it and parsing it back. + if (value == null || value.getType() != LDValueType.OBJECT) { + return buildConfigFromDefault(key, mode, defaultValue, context, variables); + } + AIConfigFlagValue parsed = AIConfigParser.parse(value); AIConfigMode flagMode = parsed.getMode() != null ? parsed.getMode() : AIConfigMode.COMPLETION; @@ -225,6 +226,56 @@ private AIConfig buildConfig( } } + /** + * Builds the typed config straight from the caller-supplied default, used when the flag is absent + * or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag. + */ + private AIConfig buildConfigFromDefault( + String key, + AIConfigMode mode, + AIConfigDefault defaultValue, + LDContext context, + Map variables) { + switch (mode) { + case AGENT: { + AIAgentConfigDefault agent = (AIAgentConfigDefault) defaultValue; + return new AIAgentConfig( + key, + agent.isEnabled(), + agent.getModel(), + agent.getProvider(), + interpolate(agent.getInstructions(), variables, context), + agent.getJudgeConfiguration(), + agent.getTools(), + TRACKER_FACTORY); + } + case JUDGE: { + AIJudgeConfigDefault judge = (AIJudgeConfigDefault) defaultValue; + return new AIJudgeConfig( + key, + judge.isEnabled(), + judge.getModel(), + judge.getProvider(), + interpolateMessages(judge.getMessages(), variables, context), + judge.getEvaluationMetricKey(), + TRACKER_FACTORY); + } + case COMPLETION: + default: { + AICompletionConfigDefault completion = (AICompletionConfigDefault) defaultValue; + return new AICompletionConfig( + key, + completion.isEnabled(), + completion.getModel(), + completion.getProvider(), + interpolateMessages(completion.getMessages(), variables, context), + completion.getJudgeConfiguration(), + completion.getTools(), + TRACKER_FACTORY); + } + } + } + private AIConfig disabledConfig(String key, AIConfigMode mode) { switch (mode) { case AGENT: @@ -253,126 +304,6 @@ private String interpolate(String template, Map variables, LDCon return interpolator.interpolate(template, variables, context); } - // --------------------------------------------------------------------------- - // Default -> flag value rendering (inverse of AIConfigParser). Kept in sync with the field names - // the parser reads so a default round-trips back to an equivalent config. - // --------------------------------------------------------------------------- - - private static LDValue toFlagValue(AIConfigDefault config, AIConfigMode mode) { - ObjectBuilder builder = LDValue.buildObject(); - builder.put("_ldMeta", LDValue.buildObject() - .put("enabled", config.isEnabled()) - .put("mode", mode.getWireValue()) - .build()); - - if (config.getModel() != null) { - builder.put("model", modelToLdValue(config.getModel())); - } - if (config.getProvider() != null) { - builder.put("provider", providerToLdValue(config.getProvider())); - } - - if (config instanceof AICompletionConfigDefault) { - AICompletionConfigDefault completion = (AICompletionConfigDefault) config; - putMessages(builder, completion.getMessages()); - putJudgeConfiguration(builder, completion.getJudgeConfiguration()); - putTools(builder, completion.getTools()); - } else if (config instanceof AIAgentConfigDefault) { - AIAgentConfigDefault agent = (AIAgentConfigDefault) config; - if (agent.getInstructions() != null) { - builder.put("instructions", agent.getInstructions()); - } - putJudgeConfiguration(builder, agent.getJudgeConfiguration()); - putTools(builder, agent.getTools()); - } else if (config instanceof AIJudgeConfigDefault) { - AIJudgeConfigDefault judge = (AIJudgeConfigDefault) config; - putMessages(builder, judge.getMessages()); - if (judge.getEvaluationMetricKey() != null) { - builder.put("evaluationMetricKey", judge.getEvaluationMetricKey()); - } - } - - return builder.build(); - } - - private static LDValue modelToLdValue(ModelConfig model) { - ObjectBuilder builder = LDValue.buildObject(); - if (model.getName() != null) { - builder.put("name", model.getName()); - } - if (!model.getParameters().isEmpty()) { - builder.put("parameters", LDValueConverter.fromJavaObject(model.getParameters())); - } - if (!model.getCustom().isEmpty()) { - builder.put("custom", LDValueConverter.fromJavaObject(model.getCustom())); - } - return builder.build(); - } - - private static LDValue providerToLdValue(ProviderConfig provider) { - ObjectBuilder builder = LDValue.buildObject(); - if (provider.getName() != null) { - builder.put("name", provider.getName()); - } - return builder.build(); - } - - private static void putMessages(ObjectBuilder builder, List messages) { - if (messages == null) { - return; - } - ArrayBuilder array = LDValue.buildArray(); - for (LDMessage message : messages) { - array.add(LDValue.buildObject() - .put("role", message.getRole().getWireValue()) - .put("content", message.getContent()) - .build()); - } - builder.put("messages", array.build()); - } - - private static void putJudgeConfiguration(ObjectBuilder builder, JudgeConfiguration judgeConfiguration) { - if (judgeConfiguration == null) { - return; - } - ArrayBuilder judges = LDValue.buildArray(); - for (JudgeConfiguration.Judge judge : judgeConfiguration.getJudges()) { - judges.add(LDValue.buildObject() - .put("key", judge.getKey()) - .put("samplingRate", judge.getSamplingRate()) - .build()); - } - builder.put("judgeConfiguration", LDValue.buildObject().put("judges", judges.build()).build()); - } - - private static void putTools(ObjectBuilder builder, Map tools) { - if (tools == null) { - return; - } - ObjectBuilder toolsObject = LDValue.buildObject(); - for (Map.Entry entry : tools.entrySet()) { - ToolConfig tool = entry.getValue(); - ObjectBuilder toolObject = LDValue.buildObject(); - if (tool.getName() != null) { - toolObject.put("name", tool.getName()); - } - if (tool.getDescription() != null) { - toolObject.put("description", tool.getDescription()); - } - if (tool.getType() != null) { - toolObject.put("type", tool.getType()); - } - if (!tool.getParameters().isEmpty()) { - toolObject.put("parameters", LDValueConverter.fromJavaObject(tool.getParameters())); - } - if (!tool.getCustomParameters().isEmpty()) { - toolObject.put("customParameters", LDValueConverter.fromJavaObject(tool.getCustomParameters())); - } - toolsObject.put(entry.getKey(), toolObject.build()); - } - builder.put("tools", toolsObject.build()); - } - private static LDLogger defaultLogger() { LDLogAdapter adapter; try { 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 index 48d3ee84..249b2eed 100644 --- 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 @@ -1,9 +1,7 @@ package com.launchdarkly.sdk.server.ai.internal; -import com.launchdarkly.sdk.ArrayBuilder; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; -import com.launchdarkly.sdk.ObjectBuilder; import java.util.ArrayList; import java.util.Collections; @@ -71,64 +69,6 @@ public static Map toMap(LDValue value) { return null; } - /** - * Converts a plain Java value back into an {@link LDValue}. - *

    - * This is the inverse of {@link #toJavaObject(LDValue)} and is used to render a caller-supplied - * default config back into the JSON flag-value shape so it can flow through the base SDK's - * variation call. Supported inputs are {@link LDValue}, {@link String}, {@link Boolean}, - * {@link Number}, {@link Map} (with string keys), and {@link Iterable}; any other type (or a - * {@code null}) becomes {@link LDValue#ofNull()}. Conversion depth is capped (see - * {@link #MAX_DEPTH}); values nested more deeply are dropped to {@code null}. - * - * @param value the value to convert; may be {@code null} - * @return the equivalent {@link LDValue}, never {@code null} - */ - public static LDValue fromJavaObject(Object value) { - return fromJavaObject(value, 0); - } - - private static LDValue fromJavaObject(Object value, int depth) { - if (value == null || depth >= MAX_DEPTH) { - return LDValue.ofNull(); - } - if (value instanceof LDValue) { - return (LDValue) value; - } - if (value instanceof String) { - return LDValue.of((String) value); - } - if (value instanceof Boolean) { - return LDValue.of((Boolean) value); - } - if (value instanceof Integer) { - return LDValue.of((Integer) value); - } - if (value instanceof Long) { - return LDValue.of((Long) value); - } - if (value instanceof Number) { - return LDValue.of(((Number) value).doubleValue()); - } - if (value instanceof Map) { - ObjectBuilder builder = LDValue.buildObject(); - for (Map.Entry entry : ((Map) value).entrySet()) { - if (entry.getKey() != null) { - builder.put(entry.getKey().toString(), fromJavaObject(entry.getValue(), depth + 1)); - } - } - return builder.build(); - } - if (value instanceof Iterable) { - ArrayBuilder builder = LDValue.buildArray(); - for (Object element : (Iterable) value) { - builder.add(fromJavaObject(element, depth + 1)); - } - return builder.build(); - } - return LDValue.ofNull(); - } - private static Object convert(LDValue value, int depth) { if (value == null || value.isNull()) { return null; diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java index 2ebdf8ed..7658fd2b 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -202,7 +202,8 @@ public void matchingModeDoesNotWarn() { @Test public void absentFlagReturnsConfiguredDefault() { - // Simulate an absent flag: the base SDK echoes back the default value we passed in. + // Simulate an absent flag: the base SDK returns the null sentinel default we pass in, which the + // client treats as "flag not found" and resolves to the caller's typed default. when(client.jsonValueVariation(anyString(), any(), any())) .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); 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 index 769d2a88..17872c97 100644 --- 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 @@ -7,9 +7,6 @@ import com.launchdarkly.sdk.LDValue; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -73,43 +70,4 @@ public void deeplyNestedInputDoesNotOverflowAndIsCapped() { Object converted = LDValueConverter.toJavaObject(LDValue.parse(json.toString())); assertThat(converted, instanceOf(List.class)); } - - @Test - public void fromJavaObjectConvertsScalars() { - assertThat(LDValueConverter.fromJavaObject(null), is(LDValue.ofNull())); - assertThat(LDValueConverter.fromJavaObject("hi"), is(LDValue.of("hi"))); - assertThat(LDValueConverter.fromJavaObject(Boolean.TRUE), is(LDValue.of(true))); - assertThat(LDValueConverter.fromJavaObject(7), is(LDValue.of(7))); - assertThat(LDValueConverter.fromJavaObject(7L), is(LDValue.of(7L))); - assertThat(LDValueConverter.fromJavaObject(0.5), is(LDValue.of(0.5))); - } - - @Test - public void fromJavaObjectConvertsNestedMapsAndLists() { - Map input = new LinkedHashMap<>(); - input.put("a", 1L); - input.put("b", Arrays.asList("x", 2L)); - Map nested = new LinkedHashMap<>(); - nested.put("d", true); - input.put("c", nested); - - LDValue value = LDValueConverter.fromJavaObject(input); - assertThat(value, is(LDValue.parse("{\"a\":1,\"b\":[\"x\",2],\"c\":{\"d\":true}}"))); - } - - @Test - public void fromJavaObjectRoundTrips() { - LDValue original = LDValue.parse("{\"name\":\"gpt-4\",\"n\":3,\"f\":0.25,\"on\":true,\"list\":[1,2]}"); - Object asJava = LDValueConverter.toJavaObject(original); - assertThat(LDValueConverter.fromJavaObject(asJava), is(original)); - } - - @Test - public void fromJavaObjectDropsUnsupportedTypes() { - // An unsupported value type becomes JSON null rather than throwing. - assertThat(LDValueConverter.fromJavaObject(new Object()), is(LDValue.ofNull())); - List list = new ArrayList<>(); - list.add(new Object()); - assertThat(LDValueConverter.fromJavaObject(list), is(LDValue.parse("[null]"))); - } } From 18ea992cf740d06a0755ae3344a120bbc388de95 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 10 Jun 2026 19:46:26 -0400 Subject: [PATCH 10/12] refactor: adopt LDAIConfigTypes nested names in config types (AIC-2663) Update the AIConfig hierarchy, LDAIClientImpl, and tests to reference the consolidated LDAIConfigTypes.{Mode,Message,Model,Provider,Tool,JudgeConfiguration} types introduced on the data-model PR. Co-authored-by: Cursor --- .../sdk/server/ai/AIAgentConfig.java | 22 +++++++------- .../sdk/server/ai/AIAgentConfigDefault.java | 12 ++++---- .../sdk/server/ai/AICompletionConfig.java | 30 +++++++++---------- .../server/ai/AICompletionConfigDefault.java | 22 +++++++------- .../launchdarkly/sdk/server/ai/AIConfig.java | 24 +++++++-------- .../sdk/server/ai/AIConfigDefault.java | 20 ++++++------- .../sdk/server/ai/AIJudgeConfig.java | 20 ++++++------- .../sdk/server/ai/AIJudgeConfigDefault.java | 10 +++---- .../sdk/server/ai/LDAIClientImpl.java | 28 ++++++++--------- .../sdk/server/ai/LDAIClientImplTest.java | 22 +++++++------- 10 files changed, 105 insertions(+), 105 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java index 77d65bee..5df6b067 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java @@ -1,10 +1,10 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; import java.util.Collections; import java.util.Map; @@ -20,18 +20,18 @@ public final class AIAgentConfig extends AIConfig { private final String instructions; private final JudgeConfiguration judgeConfiguration; - private final Map tools; + private final Map tools; AIAgentConfig( String key, boolean enabled, - ModelConfig model, - ProviderConfig provider, + Model model, + Provider provider, String instructions, JudgeConfiguration judgeConfiguration, - Map tools, + Map tools, Supplier trackerFactory) { - super(key, enabled, AIConfigMode.AGENT, model, provider, trackerFactory); + super(key, enabled, Mode.AGENT, model, provider, trackerFactory); this.instructions = instructions; this.judgeConfiguration = judgeConfiguration; this.tools = tools == null ? null : Collections.unmodifiableMap(tools); @@ -60,7 +60,7 @@ public JudgeConfiguration getJudgeConfiguration() { * * @return an unmodifiable map of tools, or {@code null} if none were specified */ - public Map getTools() { + public Map getTools() { return tools; } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java index c87ca565..74f46fed 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; -import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; import java.util.Collections; import java.util.LinkedHashMap; @@ -16,7 +16,7 @@ public final class AIAgentConfigDefault extends AIConfigDefault { private final String instructions; private final JudgeConfiguration judgeConfiguration; - private final Map tools; + private final Map tools; private AIAgentConfigDefault(Builder builder) { super(builder); @@ -49,7 +49,7 @@ public JudgeConfiguration getJudgeConfiguration() { * * @return an unmodifiable map of tools, or {@code null} if none were specified */ - public Map getTools() { + public Map getTools() { return tools; } @@ -77,7 +77,7 @@ public static AIAgentConfigDefault disabled() { public static final class Builder extends AbstractBuilder { private String instructions; private JudgeConfiguration judgeConfiguration; - private Map tools; + private Map tools; private Builder() { } @@ -115,7 +115,7 @@ public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { * @param tools the tools; may be {@code null} * @return this builder */ - public Builder tools(Map tools) { + public Builder tools(Map tools) { this.tools = tools; return this; } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java index 27438977..0a15aca0 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java @@ -1,11 +1,11 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; -import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; import java.util.Collections; import java.util.List; @@ -20,20 +20,20 @@ * supplied variables and evaluation context. Instances are immutable. */ public final class AICompletionConfig extends AIConfig { - private final List messages; + private final List messages; private final JudgeConfiguration judgeConfiguration; - private final Map tools; + private final Map tools; AICompletionConfig( String key, boolean enabled, - ModelConfig model, - ProviderConfig provider, - List messages, + Model model, + Provider provider, + List messages, JudgeConfiguration judgeConfiguration, - Map tools, + Map tools, Supplier trackerFactory) { - super(key, enabled, AIConfigMode.COMPLETION, model, provider, trackerFactory); + super(key, enabled, Mode.COMPLETION, model, provider, trackerFactory); this.messages = messages == null ? null : Collections.unmodifiableList(messages); this.judgeConfiguration = judgeConfiguration; this.tools = tools == null ? null : Collections.unmodifiableMap(tools); @@ -44,7 +44,7 @@ public final class AICompletionConfig extends AIConfig { * * @return an unmodifiable list of messages, or {@code null} if none were specified */ - public List getMessages() { + public List getMessages() { return messages; } @@ -62,7 +62,7 @@ public JudgeConfiguration getJudgeConfiguration() { * * @return an unmodifiable map of tools, or {@code null} if none were specified */ - public Map getTools() { + public Map getTools() { return tools; } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java index 8b11b298..11ee3c67 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java @@ -1,8 +1,8 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; -import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; -import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; import java.util.ArrayList; import java.util.Collections; @@ -17,9 +17,9 @@ * Build instances with {@link #builder()}. Instances are immutable. */ public final class AICompletionConfigDefault extends AIConfigDefault { - private final List messages; + private final List messages; private final JudgeConfiguration judgeConfiguration; - private final Map tools; + private final Map tools; private AICompletionConfigDefault(Builder builder) { super(builder); @@ -35,7 +35,7 @@ private AICompletionConfigDefault(Builder builder) { * * @return an unmodifiable list of messages, or {@code null} if none were specified */ - public List getMessages() { + public List getMessages() { return messages; } @@ -53,7 +53,7 @@ public JudgeConfiguration getJudgeConfiguration() { * * @return an unmodifiable map of tools, or {@code null} if none were specified */ - public Map getTools() { + public Map getTools() { return tools; } @@ -79,9 +79,9 @@ public static AICompletionConfigDefault disabled() { * Builder for {@link AICompletionConfigDefault}. */ public static final class Builder extends AbstractBuilder { - private List messages; + private List messages; private JudgeConfiguration judgeConfiguration; - private Map tools; + private Map tools; private Builder() { } @@ -97,7 +97,7 @@ protected Builder self() { * @param messages the messages; may be {@code null} * @return this builder */ - public Builder messages(List messages) { + public Builder messages(List messages) { this.messages = messages; return this; } @@ -119,7 +119,7 @@ public Builder judgeConfiguration(JudgeConfiguration judgeConfiguration) { * @param tools the tools; may be {@code null} * @return this builder */ - public Builder tools(Map tools) { + public Builder tools(Map tools) { this.tools = tools; return this; } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java index 7b28fb8e..22820f08 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java @@ -1,8 +1,8 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider; import java.util.Objects; import java.util.function.Supplier; @@ -20,17 +20,17 @@ public abstract class AIConfig { private final String key; private final boolean enabled; - private final AIConfigMode mode; - private final ModelConfig model; - private final ProviderConfig provider; + private final Mode mode; + private final Model model; + private final Provider provider; private final Supplier trackerFactory; AIConfig( String key, boolean enabled, - AIConfigMode mode, - ModelConfig model, - ProviderConfig provider, + Mode mode, + Model model, + Provider provider, Supplier trackerFactory) { this.key = key; this.enabled = enabled; @@ -66,7 +66,7 @@ public boolean isEnabled() { * * @return the mode, never {@code null} */ - public AIConfigMode getMode() { + public Mode getMode() { return mode; } @@ -75,7 +75,7 @@ public AIConfigMode getMode() { * * @return the model, or {@code null} if none was specified */ - public ModelConfig getModel() { + public Model getModel() { return model; } @@ -84,7 +84,7 @@ public ModelConfig getModel() { * * @return the provider, or {@code null} if none was specified */ - public ProviderConfig getProvider() { + public Provider getProvider() { return provider; } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java index f5e26d86..2ec4e0eb 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider; /** * The common, mode-independent surface of a caller-supplied default AI Config. @@ -14,8 +14,8 @@ */ public abstract class AIConfigDefault { private final Boolean enabled; - private final ModelConfig model; - private final ProviderConfig provider; + private final Model model; + private final Provider provider; AIConfigDefault(AbstractBuilder builder) { this.enabled = builder.enabled; @@ -46,7 +46,7 @@ public boolean isEnabled() { * * @return the model, or {@code null} if none was specified */ - public ModelConfig getModel() { + public Model getModel() { return model; } @@ -55,7 +55,7 @@ public ModelConfig getModel() { * * @return the provider, or {@code null} if none was specified */ - public ProviderConfig getProvider() { + public Provider getProvider() { return provider; } @@ -69,8 +69,8 @@ public ProviderConfig getProvider() { */ protected abstract static class AbstractBuilder> { private Boolean enabled; - private ModelConfig model; - private ProviderConfig provider; + private Model model; + private Provider provider; /** * Returns this builder as the concrete subtype. @@ -96,7 +96,7 @@ public B enabled(boolean enabled) { * @param model the model configuration; may be {@code null} * @return this builder */ - public B model(ModelConfig model) { + public B model(Model model) { this.model = model; return self(); } @@ -107,7 +107,7 @@ public B model(ModelConfig model) { * @param provider the provider configuration; may be {@code null} * @return this builder */ - public B provider(ProviderConfig provider) { + public B provider(Provider provider) { this.provider = provider; return self(); } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java index f2e456b1..0c6245b1 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java @@ -1,9 +1,9 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; -import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider; import java.util.Collections; import java.util.List; @@ -18,18 +18,18 @@ * immutable. */ public final class AIJudgeConfig extends AIConfig { - private final List messages; + private final List messages; private final String evaluationMetricKey; AIJudgeConfig( String key, boolean enabled, - ModelConfig model, - ProviderConfig provider, - List messages, + Model model, + Provider provider, + List messages, String evaluationMetricKey, Supplier trackerFactory) { - super(key, enabled, AIConfigMode.JUDGE, model, provider, trackerFactory); + super(key, enabled, Mode.JUDGE, model, provider, trackerFactory); this.messages = messages == null ? null : Collections.unmodifiableList(messages); this.evaluationMetricKey = evaluationMetricKey; } @@ -39,7 +39,7 @@ public final class AIJudgeConfig extends AIConfig { * * @return an unmodifiable list of messages, or {@code null} if none were specified */ - public List getMessages() { + public List getMessages() { return messages; } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java index 5c12fff7..83e09807 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java @@ -1,6 +1,6 @@ package com.launchdarkly.sdk.server.ai; -import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; import java.util.ArrayList; import java.util.Collections; @@ -13,7 +13,7 @@ * Build instances with {@link #builder()}. Instances are immutable. */ public final class AIJudgeConfigDefault extends AIConfigDefault { - private final List messages; + private final List messages; private final String evaluationMetricKey; private AIJudgeConfigDefault(Builder builder) { @@ -28,7 +28,7 @@ private AIJudgeConfigDefault(Builder builder) { * * @return an unmodifiable list of messages, or {@code null} if none were specified */ - public List getMessages() { + public List getMessages() { return messages; } @@ -63,7 +63,7 @@ public static AIJudgeConfigDefault disabled() { * Builder for {@link AIJudgeConfigDefault}. */ public static final class Builder extends AbstractBuilder { - private List messages; + private List messages; private String evaluationMetricKey; private Builder() { @@ -80,7 +80,7 @@ protected Builder self() { * @param messages the messages; may be {@code null} * @return this builder */ - public Builder messages(List messages) { + public Builder messages(List messages) { this.messages = messages; return this; } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java index ae0aa6b5..3269621b 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java @@ -8,8 +8,8 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; -import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; import com.launchdarkly.sdk.server.ai.internal.AIConfigFlagValue; import com.launchdarkly.sdk.server.ai.internal.AIConfigParser; import com.launchdarkly.sdk.server.ai.internal.AISdkInfo; @@ -101,7 +101,7 @@ public AICompletionConfig completionConfig( client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1); AICompletionConfigDefault effectiveDefault = defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled(); - return (AICompletionConfig) evaluate(key, context, effectiveDefault, AIConfigMode.COMPLETION, variables); + return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables); } @Override @@ -143,14 +143,14 @@ public AIJudgeConfig judgeConfig( client.trackMetric(TRACK_USAGE_JUDGE_CONFIG, context, LDValue.of(key), 1); AIJudgeConfigDefault effectiveDefault = defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled(); - return (AIJudgeConfig) evaluate(key, context, effectiveDefault, AIConfigMode.JUDGE, variables); + return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables); } private AIAgentConfig evaluateAgent( String key, LDContext context, AIAgentConfigDefault defaultValue, Map variables) { AIAgentConfigDefault effectiveDefault = defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled(); - return (AIAgentConfig) evaluate(key, context, effectiveDefault, AIConfigMode.AGENT, variables); + return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables); } /** @@ -162,7 +162,7 @@ private AIConfig evaluate( String key, LDContext context, AIConfigDefault defaultValue, - AIConfigMode mode, + Mode mode, Map variables) { LDValue value = client.jsonValueVariation(key, context, LDValue.ofNull()); @@ -175,7 +175,7 @@ private AIConfig evaluate( AIConfigFlagValue parsed = AIConfigParser.parse(value); - AIConfigMode flagMode = parsed.getMode() != null ? parsed.getMode() : AIConfigMode.COMPLETION; + Mode flagMode = parsed.getMode() != null ? parsed.getMode() : Mode.COMPLETION; if (flagMode != mode) { logger.warn( "AI Config mode mismatch for {}: expected {}, got {}. Returning disabled config.", @@ -188,7 +188,7 @@ private AIConfig evaluate( private AIConfig buildConfig( String key, - AIConfigMode mode, + Mode mode, AIConfigFlagValue parsed, LDContext context, Map variables) { @@ -232,7 +232,7 @@ private AIConfig buildConfig( */ private AIConfig buildConfigFromDefault( String key, - AIConfigMode mode, + Mode mode, AIConfigDefault defaultValue, LDContext context, Map variables) { @@ -276,7 +276,7 @@ private AIConfig buildConfigFromDefault( } } - private AIConfig disabledConfig(String key, AIConfigMode mode) { + private AIConfig disabledConfig(String key, Mode mode) { switch (mode) { case AGENT: return new AIAgentConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); @@ -288,13 +288,13 @@ private AIConfig disabledConfig(String key, AIConfigMode mode) { } } - private List interpolateMessages( - List messages, Map variables, LDContext context) { + private List interpolateMessages( + List messages, Map variables, LDContext context) { if (messages == null) { return null; } - List result = new ArrayList<>(messages.size()); - for (LDMessage message : messages) { + List result = new ArrayList<>(messages.size()); + for (Message message : messages) { result.add(message.withContent(interpolator.interpolate(message.getContent(), variables, context))); } return result; diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java index 7658fd2b..11253f45 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -21,9 +21,9 @@ import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; -import com.launchdarkly.sdk.server.ai.datamodel.LDMessage; -import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import java.util.ArrayList; @@ -129,11 +129,11 @@ public void completionConfigReturnsTypedConfigFromVariation() { assertThat(config, is(notNullValue())); assertThat(config.getKey(), is("key")); assertThat(config.isEnabled(), is(true)); - assertThat(config.getMode(), is(AIConfigMode.COMPLETION)); + assertThat(config.getMode(), is(Mode.COMPLETION)); assertThat(config.getModel().getName(), is("gpt-4")); assertThat(config.getMessages(), hasSize(1)); assertThat(config.getMessages().get(0).getContent(), is("Hello World")); - assertThat(config.getMessages().get(0).getRole(), is(LDMessage.Role.SYSTEM)); + assertThat(config.getMessages().get(0).getRole(), is(Message.Role.SYSTEM)); } @Test @@ -156,7 +156,7 @@ public void agentConfigInterpolatesInstructions() { variables.put("topic", "climate"); AIAgentConfig config = ai.agentConfig("key", context, null, variables); - assertThat(config.getMode(), is(AIConfigMode.AGENT)); + assertThat(config.getMode(), is(Mode.AGENT)); assertThat(config.getInstructions(), is("You research climate")); } @@ -167,7 +167,7 @@ public void judgeConfigResolvesFirstNonBlankEvaluationMetricKey() { when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); AIJudgeConfig config = ai.judgeConfig("key", context, null, null); - assertThat(config.getMode(), is(AIConfigMode.JUDGE)); + assertThat(config.getMode(), is(Mode.JUDGE)); assertThat(config.getEvaluationMetricKey(), is("relevance")); } @@ -185,7 +185,7 @@ public void modeMismatchReturnsDisabledConfigAndWarnsOnce() { assertThat(config, is(notNullValue())); assertThat(config.isEnabled(), is(false)); assertThat(config.getMessages(), is(nullValue())); - assertThat(config.getMode(), is(AIConfigMode.COMPLETION)); + assertThat(config.getMode(), is(Mode.COMPLETION)); assertThat(warnings(), hasSize(1)); assertThat(warnings().get(0), containsString("mode mismatch")); } @@ -209,8 +209,8 @@ public void absentFlagReturnsConfiguredDefault() { AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() .enabled(true) - .model(ModelConfig.builder("default-model").build()) - .messages(Arrays.asList(new LDMessage(LDMessage.Role.SYSTEM, "default {{x}}"))) + .model(Model.builder("default-model").build()) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "default {{x}}"))) .build(); Map variables = new HashMap<>(); @@ -240,7 +240,7 @@ public void doesNotMergeMissingFieldsFromDefault() { AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() .enabled(false) - .messages(Arrays.asList(new LDMessage(LDMessage.Role.SYSTEM, "should not appear"))) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "should not appear"))) .build(); AICompletionConfig config = ai.completionConfig("key", context, dflt, variables(/* none */)); From dfc1386f537fbd688fccc7f8b7974dec69dc406a Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 11 Jun 2026 16:43:15 -0400 Subject: [PATCH 11/12] refactor: address PR #173 review feedback (AIC-2663) - Return the caller's default config (not a hard-disabled config) on AI Config mode mismatch, per the recent spec change; drop the now-unused disabledConfig helper and update the LDAIClient docs/test accordingly. - Remove the unnecessary try/catch around the SDK-info trackMetric call in the constructor (the call cannot throw) and the test that only passed via a throwing mock. - Use release-please block markers on the AISdkInfo VERSION line and register AISdkInfo.java in the package's extra-files so the version is actually bumped. Co-authored-by: Cursor --- .../sdk/server/ai/LDAIClient.java | 8 ++--- .../sdk/server/ai/LDAIClientImpl.java | 34 +++++-------------- .../sdk/server/ai/internal/AISdkInfo.java | 3 +- .../sdk/server/ai/LDAIClientImplTest.java | 28 +++++++-------- 4 files changed, 28 insertions(+), 45 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java index 16e38e06..16ce751d 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java @@ -14,10 +14,10 @@ * variables (and the evaluation context, exposed to templates as {@code ldctx}), and returns a * strongly-typed config. *

    - * When the flag is absent or cannot be evaluated, the caller-supplied default is returned as the - * corresponding config type. When the variation's mode does not match the requested kind, a - * disabled config of the requested type is returned and a warning is logged; a config is never - * returned in a state that would force the caller into a {@code NullPointerException}. + * When the flag is absent, cannot be evaluated, or its mode does not match the requested kind, the + * caller-supplied default is returned as the corresponding config type (a warning is logged on a + * mode mismatch); a config is never returned in a state that would force the caller into a + * {@code NullPointerException}. *

    * Implementations are thread-safe. */ diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java index 3269621b..cb6fe4ad 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java @@ -78,18 +78,12 @@ public LDAIClientImpl(LDClientInterface client, LDLogger logger) { this.logger = Objects.requireNonNull(logger, "logger"); this.interpolator = new Interpolator(); - // Report SDK info once. Guard it: if the base client is not yet fully initialized, a track call - // must never propagate an exception out of this constructor. - try { - LDValue info = LDValue.buildObject() - .put("aiSdkName", AISdkInfo.NAME) - .put("aiSdkVersion", AISdkInfo.VERSION) - .put("aiSdkLanguage", AISdkInfo.LANGUAGE) - .build(); - client.trackMetric(TRACK_SDK_INFO, INIT_TRACK_CONTEXT, info, 1); - } catch (Exception e) { - this.logger.warn("Unable to record AI SDK info event: {}", e.toString()); - } + LDValue info = LDValue.buildObject() + .put("aiSdkName", AISdkInfo.NAME) + .put("aiSdkVersion", AISdkInfo.VERSION) + .put("aiSdkLanguage", AISdkInfo.LANGUAGE) + .build(); + client.trackMetric(TRACK_SDK_INFO, INIT_TRACK_CONTEXT, info, 1); } @Override @@ -178,9 +172,9 @@ private AIConfig evaluate( Mode flagMode = parsed.getMode() != null ? parsed.getMode() : Mode.COMPLETION; if (flagMode != mode) { logger.warn( - "AI Config mode mismatch for {}: expected {}, got {}. Returning disabled config.", + "AI Config mode mismatch for {}: expected {}, got {}. Returning default config.", key, mode.getWireValue(), flagMode.getWireValue()); - return disabledConfig(key, mode); + return buildConfigFromDefault(key, mode, defaultValue, context, variables); } return buildConfig(key, mode, parsed, context, variables); @@ -276,18 +270,6 @@ private AIConfig buildConfigFromDefault( } } - private AIConfig disabledConfig(String key, Mode mode) { - switch (mode) { - case AGENT: - return new AIAgentConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); - case JUDGE: - return new AIJudgeConfig(key, false, null, null, null, null, TRACKER_FACTORY); - case COMPLETION: - default: - return new AICompletionConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); - } - } - private List interpolateMessages( List messages, Map variables, LDContext context) { if (messages == null) { diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java index 42cdee10..6e76357f 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java @@ -25,8 +25,9 @@ public final class AISdkInfo { * lookup so that it resolves correctly in unit tests and when the classes are used outside the * packaged jar. */ - // x-release-please-version + // x-release-please-start-version public static final String VERSION = "0.1.0"; + // x-release-please-end private AISdkInfo() { } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java index 11253f45..b9bebb99 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -71,15 +71,6 @@ public void constructorEmitsSdkInfoEvent() { verify(client).trackMetric(eq("$ld:ai:sdk:info"), any(LDContext.class), eq(expected), eq(1.0)); } - @Test - public void constructorDoesNotThrowWhenSdkInfoTrackingFails() { - LDClientInterface throwingClient = mock(LDClientInterface.class); - org.mockito.Mockito.doThrow(new RuntimeException("not initialized")) - .when(throwingClient).trackMetric(eq("$ld:ai:sdk:info"), any(), any(), eq(1.0)); - // Must not propagate out of the constructor. - new LDAIClientImpl(throwingClient, logger); - } - // ---- Usage events --------------------------------------------------------- @Test @@ -174,18 +165,27 @@ public void judgeConfigResolvesFirstNonBlankEvaluationMetricKey() { // ---- Mode validation ------------------------------------------------------ @Test - public void modeMismatchReturnsDisabledConfigAndWarnsOnce() { + public void modeMismatchReturnsDefaultConfigAndWarnsOnce() { String agentJson = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + "\"instructions\":\"hi\"}"; when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(agentJson)); - // Requesting a completion config against an agent-mode flag. - AICompletionConfig config = ai.completionConfig("key", context, null, null); + // Requesting a completion config against an agent-mode flag returns the caller's default. + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(true) + .model(Model.builder("default-model").build()) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "default {{x}}"))) + .build(); + Map variables = new HashMap<>(); + variables.put("x", "value"); + + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables); assertThat(config, is(notNullValue())); - assertThat(config.isEnabled(), is(false)); - assertThat(config.getMessages(), is(nullValue())); assertThat(config.getMode(), is(Mode.COMPLETION)); + assertThat(config.isEnabled(), is(true)); + assertThat(config.getModel().getName(), is("default-model")); + assertThat(config.getMessages().get(0).getContent(), is("default value")); assertThat(warnings(), hasSize(1)); assertThat(warnings().get(0), containsString("mode mismatch")); } From 0f65ef89d06b2d775eeb11f7798d45dcc482c33f Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 12 Jun 2026 12:52:21 -0400 Subject: [PATCH 12/12] fix: report agent-configs usage count excluding null entries (AIC-2663) The $ld:ai:usage:agent-configs metric was reported with the full request list size before the loop, while null entries are skipped during evaluation. This could make the reported usage value/count exceed the number of configs actually retrieved. Count only the non-null requests that are evaluated and report the metric after the loop. Co-authored-by: Cursor --- .../launchdarkly/sdk/server/ai/LDAIClientImpl.java | 7 ++++--- .../sdk/server/ai/LDAIClientImplTest.java | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java index cb6fe4ad..650fdeed 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java @@ -111,20 +111,21 @@ public AIAgentConfig agentConfig( @Override public Map agentConfigs( List agentConfigs, LDContext context) { - int count = agentConfigs == null ? 0 : agentConfigs.size(); - client.trackMetric(TRACK_USAGE_AGENT_CONFIGS, context, LDValue.of(count), count); - Map result = new LinkedHashMap<>(); + int count = 0; if (agentConfigs != null) { for (AIAgentConfigRequest request : agentConfigs) { if (request == null) { continue; } + count++; result.put( request.getKey(), evaluateAgent(request.getKey(), context, request.getDefaultValue(), request.getVariables())); } } + client.trackMetric(TRACK_USAGE_AGENT_CONFIGS, context, LDValue.of(count), count); + return result; } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java index b9bebb99..9adc857a 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -2,6 +2,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasSize; @@ -275,6 +276,19 @@ public void agentConfigsHandlesEmptyList() { verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(0)), eq(0.0)); } + @Test + public void agentConfigsUsageCountExcludesNullEntries() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + List requests = Arrays.asList( + AIAgentConfigRequest.builder("a").build(), + null, + AIAgentConfigRequest.builder("b").build()); + Map agents = ai.agentConfigs(requests, context); + + assertThat(agents.keySet(), containsInAnyOrder("a", "b")); + verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(2)), eq(2.0)); + } + private static Map variables() { return new HashMap<>(); }