From aa422ca627fc3d60d9798a78e11bef9ceaa6257c Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 5 Jun 2026 11:32:51 -0400 Subject: [PATCH 1/5] feat: add AI Config data model, defensive parsing & Mustache interpolation (AIC-2662) Implements Step 2 of the Java AI SDK: the AICONF data model plus the JSON-protocol parsing and interpolation layers. No client methods, tracker, or evaluation are included (those are later steps). Public data model (com.launchdarkly.sdk.server.ai.datamodel): - LDMessage (role enum + content), ModelConfig, ProviderConfig, ToolConfig, JudgeConfiguration (+ nested Judge), AIConfigMode. - Immutable, builder-based, documented public types. Internal parsing/interpolation (com.launchdarkly.sdk.server.ai.internal): - LDValueConverter: depth-capped LDValue -> plain Java tree. Integral numbers within +/-2^53 decode to Long, otherwise Double (precision beyond 2^53 is documented). - AIConfigParser + AIConfigFlagValue: defensive LDValue -> typed parse. Malformed/wrong-typed fields never throw; tools fall back to model.parameters.tools[]; evaluationMetricKey resolves to the first non-blank of evaluationMetricKey / evaluationMetricKeys[]. - Interpolator: jmustache engine matching the JS/Python escaping policy (no HTML escaping, missing/null -> ""), with ldctx merged last so it overrides any caller-supplied ldctx, and a thread-safe compiled-template cache. Build: - Re-add the (now audited) com.samskivert:jmustache:1.16 dependency as implementation scope; flip javadoc failOnError back on now that public types exist. Tests: 32 unit tests covering defensive parsing fallbacks, number/tool/ metric-key resolution, tri-state enabled, and interpolation parity (escaping, missing-variable, ldctx-wins). Co-authored-by: Cursor --- lib/sdk/server-ai/build.gradle | 19 +- .../sdk/server/ai/datamodel/AIConfigMode.java | 57 ++++ .../ai/datamodel/JudgeConfiguration.java | 121 +++++++ .../sdk/server/ai/datamodel/LDMessage.java | 130 +++++++ .../sdk/server/ai/datamodel/ModelConfig.java | 157 +++++++++ .../server/ai/datamodel/ProviderConfig.java | 51 +++ .../sdk/server/ai/datamodel/ToolConfig.java | 189 +++++++++++ .../server/ai/internal/AIConfigFlagValue.java | 321 ++++++++++++++++++ .../server/ai/internal/AIConfigParser.java | 251 ++++++++++++++ .../sdk/server/ai/internal/Interpolator.java | 97 ++++++ .../server/ai/internal/LDValueConverter.java | 116 +++++++ .../ai/internal/AIConfigParserTest.java | 178 ++++++++++ .../server/ai/internal/InterpolatorTest.java | 97 ++++++ .../ai/internal/LDValueConverterTest.java | 73 ++++ 14 files changed, 1848 insertions(+), 9 deletions(-) create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java 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/LDValueConverter.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index e819f8d4..c1016eff 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -44,10 +44,13 @@ ext { 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: a Mustache templating dependency (for AI Config message/instruction interpolation) - // will be added in a later step once it has been fully audited (license / maintenance / - // transitive deps). See AIC-2662. + "sdk": "7.14.0", + // jmustache: Mustache engine for AI Config message/instruction interpolation. + // Audit (AIC-2662): com.samskivert:jmustache 1.16 is BSD 2-Clause licensed, actively + // maintained, and ships as a single self-contained jar with no transitive runtime + // dependencies (verified via the published POM). Chosen over mustache.java / Handlebars.java + // for its zero-dependency footprint; HTML escaping is disabled to match the JS/Python SDKs. + "jmustache": "1.16" ] ext.libraries = [:] @@ -56,6 +59,9 @@ dependencies { // Exposed on the public API surface (LDClientInterface), therefore `api` not `implementation`. api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}" + // Mustache templating, kept as `implementation` so it is not leaked onto consumers' classpath. + implementation "com.samskivert:jmustache:${versions.jmustache}" + testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "junit:junit:4.13.2" testImplementation "org.mockito:mockito-core:3.12.4" @@ -74,11 +80,6 @@ java { javadoc { // exclude internal implementation classes from the published API documentation exclude internalPackageGlob - // The foundation module (AIC-2661) intentionally ships no public types yet, only - // package-info.java. The javadoc tool reports "No public or protected classes found to - // document" in that state, so we tolerate it here. TODO(AIC-2662): set failOnError = true - // once the data-model public types land. - failOnError = false options { // suppress noisy "no comment" warnings; checkstyle enforces Javadoc on the public surface addStringOption('Xdoclint:all,-missing', '-quiet') diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java new file mode 100644 index 00000000..7752a56f --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java @@ -0,0 +1,57 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +/** + * The mode of an AI Config, as carried by the {@code _ldMeta.mode} field of a flag variation. + *

+ * The mode determines which kind of configuration a variation represents and which retrieval + * method on the client it is valid for. + */ +public enum AIConfigMode { + /** + * A completion (chat/prompt) configuration. This is the default when no mode is present. + */ + COMPLETION("completion"), + + /** + * An agent configuration, which carries {@code instructions} instead of {@code messages}. + */ + AGENT("agent"), + + /** + * A judge configuration, used to evaluate the output of another configuration. + */ + JUDGE("judge"); + + private final String wireValue; + + AIConfigMode(String wireValue) { + this.wireValue = wireValue; + } + + /** + * Returns the string used to represent this mode in the JSON protocol. + * + * @return the wire representation (for example {@code "completion"}) + */ + public String getWireValue() { + return wireValue; + } + + /** + * Resolves a wire string to a mode. + * + * @param value the wire value, such as {@code "agent"}; may be {@code null} + * @return the matching mode, or {@code null} if the value is {@code null} or unrecognized + */ + public static AIConfigMode fromWireValue(String value) { + if (value == null) { + return null; + } + for (AIConfigMode mode : values()) { + if (mode.wireValue.equals(value)) { + return mode; + } + } + return null; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java new file mode 100644 index 00000000..57752ebd --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java @@ -0,0 +1,121 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Configuration referencing the judges that may evaluate an AI Config. + *

+ * This is parsed from the {@code judgeConfiguration} field of a flag variation and is visible on + * completion and agent configs. In v1.0 judges are invoked manually; the SDK does not auto-attach + * them. Instances are immutable. + */ +public final class JudgeConfiguration { + /** + * Configuration for a single judge attachment: which judge AI Config to use and how frequently + * to sample it. + *

+ * Instances are immutable. + */ + public static final class Judge { + private final String key; + private final double samplingRate; + + /** + * Constructs a judge attachment. + * + * @param key the key of the judge AI Config; must not be {@code null} + * @param samplingRate the sampling rate, nominally in the range {@code 0.0}–{@code 1.0} + * @throws NullPointerException if {@code key} is {@code null} + */ + public Judge(String key, double samplingRate) { + this.key = Objects.requireNonNull(key, "key"); + this.samplingRate = samplingRate; + } + + /** + * Returns the key of the judge AI Config. + * + * @return the judge key, never {@code null} + */ + public String getKey() { + return key; + } + + /** + * Returns the configured sampling rate. + * + * @return the sampling rate + */ + public double getSamplingRate() { + return samplingRate; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Judge)) { + return false; + } + Judge other = (Judge) o; + return Double.compare(samplingRate, other.samplingRate) == 0 && key.equals(other.key); + } + + @Override + public int hashCode() { + return Objects.hash(key, samplingRate); + } + + @Override + public String toString() { + return "Judge{key=" + key + ", samplingRate=" + samplingRate + '}'; + } + } + + private final List judges; + + /** + * Constructs a judge configuration. + * + * @param judges the judge attachments; may be {@code null}, treated as empty + */ + public JudgeConfiguration(List judges) { + this.judges = judges == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(judges)); + } + + /** + * Returns the configured judge attachments as an unmodifiable list. + * + * @return the judges; never {@code null} (empty when none were specified) + */ + public List getJudges() { + return judges; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof JudgeConfiguration)) { + return false; + } + return judges.equals(((JudgeConfiguration) o).judges); + } + + @Override + public int hashCode() { + return judges.hashCode(); + } + + @Override + public String toString() { + return "JudgeConfiguration{judges=" + judges + '}'; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java new file mode 100644 index 00000000..2eeeeed0 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java @@ -0,0 +1,130 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.Objects; + +/** + * A single prompt message in an AI Config, consisting of a {@link Role} and string content. + *

+ * Instances are immutable. + */ +public final class LDMessage { + /** + * The role of a {@link LDMessage}. + */ + public enum Role { + /** + * A system message, typically used to set behavior or context. + */ + SYSTEM("system"), + + /** + * A message authored by the end user. + */ + USER("user"), + + /** + * A message authored by the assistant (model). + */ + ASSISTANT("assistant"); + + private final String wireValue; + + Role(String wireValue) { + this.wireValue = wireValue; + } + + /** + * Returns the string used to represent this role in the JSON protocol. + * + * @return the wire representation (for example {@code "system"}) + */ + public String getWireValue() { + return wireValue; + } + + /** + * Resolves a wire string to a role. + * + * @param value the wire value, such as {@code "user"}; may be {@code null} + * @return the matching role, or {@code null} if the value is {@code null} or unrecognized + */ + public static Role fromWireValue(String value) { + if (value == null) { + return null; + } + for (Role role : values()) { + if (role.wireValue.equals(value)) { + return role; + } + } + return null; + } + } + + private final Role role; + private final String content; + + /** + * Constructs a message. + * + * @param role the role of the message; must not be {@code null} + * @param content the message content; must not be {@code null} + * @throws NullPointerException if {@code role} or {@code content} is {@code null} + */ + public LDMessage(Role role, String content) { + this.role = Objects.requireNonNull(role, "role"); + this.content = Objects.requireNonNull(content, "content"); + } + + /** + * Returns the role of this message. + * + * @return the role, never {@code null} + */ + public Role getRole() { + return role; + } + + /** + * Returns the content of this message. + * + * @return the content, never {@code null} + */ + public String getContent() { + return content; + } + + /** + * Returns a copy of this message with the given content, preserving the role. + *

+ * Used by the interpolation layer to produce a rendered message without mutating the original. + * + * @param newContent the replacement content; must not be {@code null} + * @return a new {@link LDMessage} + */ + public LDMessage withContent(String newContent) { + return new LDMessage(role, newContent); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LDMessage)) { + return false; + } + LDMessage other = (LDMessage) o; + return role == other.role && content.equals(other.content); + } + + @Override + public int hashCode() { + return Objects.hash(role, content); + } + + @Override + public String toString() { + return "LDMessage{role=" + role + ", content=" + content + '}'; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java new file mode 100644 index 00000000..515440fe --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java @@ -0,0 +1,157 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Configuration describing the model an AI Config should use. + *

+ * Instances are immutable. The {@code parameters} and {@code custom} maps hold arbitrary values + * decoded from the JSON protocol; their values are plain Java types ({@link String}, {@link Double}, + * {@link Boolean}, {@link java.util.List}, {@link java.util.Map}, or {@code null}). Build instances + * with {@link #builder(String)}. + */ +public final class ModelConfig { + private final String name; + private final Map parameters; + private final Map custom; + + private ModelConfig(String name, Map parameters, Map custom) { + this.name = name; + this.parameters = parameters; + this.custom = custom; + } + + /** + * Returns the model name (for example {@code "gpt-4"}). + * + * @return the model name, or {@code null} if none was specified + */ + public String getName() { + return name; + } + + /** + * Returns the model-specific parameters as an unmodifiable map. + * + * @return the parameters; never {@code null} (empty when none were specified) + */ + public Map getParameters() { + return parameters; + } + + /** + * Returns customer-provided custom data as an unmodifiable map. + * + * @return the custom data; never {@code null} (empty when none was specified) + */ + public Map getCustom() { + return custom; + } + + /** + * Retrieves a single model parameter by key. + * + * @param key the parameter name + * @return the value, or {@code null} if absent + */ + public Object getParameter(String key) { + return parameters.get(key); + } + + /** + * Retrieves a single custom-data entry by key. + * + * @param key the custom-data name + * @return the value, or {@code null} if absent + */ + public Object getCustom(String key) { + return custom.get(key); + } + + /** + * Creates a builder for a model with the given name. + * + * @param name the model name + * @return a new {@link Builder} + */ + public static Builder builder(String name) { + return new Builder(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ModelConfig)) { + return false; + } + ModelConfig other = (ModelConfig) o; + return Objects.equals(name, other.name) + && parameters.equals(other.parameters) + && custom.equals(other.custom); + } + + @Override + public int hashCode() { + return Objects.hash(name, parameters, custom); + } + + @Override + public String toString() { + return "ModelConfig{name=" + name + ", parameters=" + parameters + ", custom=" + custom + '}'; + } + + /** + * Builder for {@link ModelConfig}. + */ + public static final class Builder { + private final String name; + private Map parameters; + private Map custom; + + private Builder(String name) { + this.name = name; + } + + /** + * Sets the model-specific parameters. The map is copied defensively. + * + * @param parameters the parameters; may be {@code null} + * @return this builder + */ + public Builder parameters(Map parameters) { + this.parameters = parameters == null ? null : new HashMap<>(parameters); + return this; + } + + /** + * Sets customer-provided custom data. The map is copied defensively. + * + * @param custom the custom data; may be {@code null} + * @return this builder + */ + public Builder custom(Map custom) { + this.custom = custom == null ? null : new HashMap<>(custom); + return this; + } + + /** + * Builds the immutable {@link ModelConfig}. + * + * @return a new {@link ModelConfig} + */ + public ModelConfig build() { + Map params = parameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(parameters)); + Map cust = custom == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(custom)); + return new ModelConfig(name, params, cust); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java new file mode 100644 index 00000000..eb9e232e --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java @@ -0,0 +1,51 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.Objects; + +/** + * Configuration describing the provider an AI Config should use. + *

+ * Instances are immutable. + */ +public final class ProviderConfig { + private final String name; + + /** + * Constructs a provider configuration. + * + * @param name the provider name (for example {@code "openai"}); may be {@code null} + */ + public ProviderConfig(String name) { + this.name = name; + } + + /** + * Returns the provider name. + * + * @return the provider name, or {@code null} if none was specified + */ + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ProviderConfig)) { + return false; + } + return Objects.equals(name, ((ProviderConfig) o).name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } + + @Override + public String toString() { + return "ProviderConfig{name=" + name + '}'; + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java new file mode 100644 index 00000000..5bcb8b3e --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java @@ -0,0 +1,189 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * A single entry from the root-level {@code tools} map of an AI Config flag variation. + *

+ * This is distinct from {@code model.parameters.tools[]}, which is the raw array passed through to + * LLM providers. Instances are immutable; build them with {@link #builder(String)}. + */ +public final class ToolConfig { + private final String name; + private final String description; + private final String type; + private final Map parameters; + private final Map customParameters; + + private ToolConfig( + String name, + String description, + String type, + Map parameters, + Map customParameters) { + this.name = name; + this.description = description; + this.type = type; + this.parameters = parameters; + this.customParameters = customParameters; + } + + /** + * Returns the tool name. + * + * @return the tool name, or {@code null} if none was specified + */ + public String getName() { + return name; + } + + /** + * Returns the tool description. + * + * @return the description, or {@code null} if none was specified + */ + public String getDescription() { + return description; + } + + /** + * Returns the tool type. + * + * @return the type, or {@code null} if none was specified + */ + public String getType() { + return type; + } + + /** + * Returns the tool parameters as an unmodifiable map. + * + * @return the parameters; never {@code null} (empty when none were specified) + */ + public Map getParameters() { + return parameters; + } + + /** + * Returns the tool custom parameters as an unmodifiable map. + * + * @return the custom parameters; never {@code null} (empty when none were specified) + */ + public Map getCustomParameters() { + return customParameters; + } + + /** + * Creates a builder for a tool with the given name. + * + * @param name the tool name + * @return a new {@link Builder} + */ + public static Builder builder(String name) { + return new Builder(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ToolConfig)) { + return false; + } + ToolConfig other = (ToolConfig) o; + return Objects.equals(name, other.name) + && Objects.equals(description, other.description) + && Objects.equals(type, other.type) + && parameters.equals(other.parameters) + && customParameters.equals(other.customParameters); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, type, parameters, customParameters); + } + + @Override + public String toString() { + return "ToolConfig{name=" + name + ", description=" + description + ", type=" + type + + ", parameters=" + parameters + ", customParameters=" + customParameters + '}'; + } + + /** + * Builder for {@link ToolConfig}. + */ + public static final class Builder { + private final String name; + private String description; + private String type; + private Map parameters; + private Map customParameters; + + private Builder(String name) { + this.name = name; + } + + /** + * Sets the tool description. + * + * @param description the description; may be {@code null} + * @return this builder + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * Sets the tool type. + * + * @param type the type; may be {@code null} + * @return this builder + */ + public Builder type(String type) { + this.type = type; + return this; + } + + /** + * Sets the tool parameters. The map is copied defensively. + * + * @param parameters the parameters; may be {@code null} + * @return this builder + */ + public Builder parameters(Map parameters) { + this.parameters = parameters == null ? null : new HashMap<>(parameters); + return this; + } + + /** + * Sets the tool custom parameters. The map is copied defensively. + * + * @param customParameters the custom parameters; may be {@code null} + * @return this builder + */ + public Builder customParameters(Map customParameters) { + this.customParameters = customParameters == null ? null : new HashMap<>(customParameters); + return this; + } + + /** + * Builds the immutable {@link ToolConfig}. + * + * @return a new {@link ToolConfig} + */ + public ToolConfig build() { + Map params = parameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(parameters)); + Map customParams = customParameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(customParameters)); + return new ToolConfig(name, description, type, params, customParams); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java new file mode 100644 index 00000000..d393c006 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java @@ -0,0 +1,321 @@ +package com.launchdarkly.sdk.server.ai.internal; + +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; + +/** + * The parsed, strongly-typed representation of an AI Config flag variation's JSON protocol. + *

+ * This mirrors the wire structure: the {@code _ldMeta} block (enabled / variationKey / version / + * mode) plus the model, provider, messages, instructions, tools, judge configuration, and resolved + * evaluation metric key. It is produced by {@link AIConfigParser} and consumed when assembling the + * public config types (in a later step). + *

+ * Boxed types are used for {@code _ldMeta} scalars so that "absent" is distinguishable from a + * concrete value; callers decide the defaults (for example {@code enabled == null} means "treat as + * disabled" and {@code mode == null} means "treat as completion"). Instances are immutable. + *

+ * This class is an internal implementation detail and is not part of the supported API. + */ +public final class AIConfigFlagValue { + private final Boolean enabled; + private final String variationKey; + private final Integer version; + private final AIConfigMode mode; + private final ModelConfig model; + private final ProviderConfig provider; + private final List messages; + private final String instructions; + private final Map tools; + private final JudgeConfiguration judgeConfiguration; + private final String evaluationMetricKey; + + private AIConfigFlagValue(Builder b) { + this.enabled = b.enabled; + this.variationKey = b.variationKey; + this.version = b.version; + this.mode = b.mode; + this.model = b.model; + this.provider = b.provider; + this.messages = b.messages == null ? null : Collections.unmodifiableList(b.messages); + this.instructions = b.instructions; + this.tools = b.tools == null ? null : Collections.unmodifiableMap(b.tools); + this.judgeConfiguration = b.judgeConfiguration; + this.evaluationMetricKey = b.evaluationMetricKey; + } + + /** + * Returns the {@code _ldMeta.enabled} flag. + * + * @return the enabled flag, or {@code null} if absent + */ + public Boolean getEnabled() { + return enabled; + } + + /** + * Returns {@code true} if {@code _ldMeta.enabled} is explicitly {@code true}. + * + * @return whether the config is enabled, defaulting to {@code false} when absent + */ + public boolean isEnabled() { + return Boolean.TRUE.equals(enabled); + } + + /** + * Returns the {@code _ldMeta.variationKey}. + * + * @return the variation key, or {@code null} if absent + */ + public String getVariationKey() { + return variationKey; + } + + /** + * Returns the {@code _ldMeta.version}. + * + * @return the version, or {@code null} if absent + */ + public Integer getVersion() { + return version; + } + + /** + * Returns the {@code _ldMeta.mode}. + * + * @return the mode, or {@code null} if absent or unrecognized + */ + public AIConfigMode getMode() { + return mode; + } + + /** + * Returns the model configuration. + * + * @return the model, or {@code null} if absent + */ + public ModelConfig getModel() { + return model; + } + + /** + * Returns the provider configuration. + * + * @return the provider, or {@code null} if absent + */ + public ProviderConfig getProvider() { + return provider; + } + + /** + * Returns the prompt messages. + * + * @return an unmodifiable list of messages, or {@code null} if absent + */ + public List getMessages() { + return messages; + } + + /** + * Returns the agent instructions. + * + * @return the instructions, or {@code null} if absent + */ + public String getInstructions() { + return instructions; + } + + /** + * Returns the resolved root-level tools map. + * + * @return an unmodifiable map keyed by tool name, or {@code null} if absent + */ + public Map getTools() { + return tools; + } + + /** + * Returns the judge configuration. + * + * @return the judge configuration, or {@code null} if absent + */ + public JudgeConfiguration getJudgeConfiguration() { + return judgeConfiguration; + } + + /** + * Returns the resolved evaluation metric key. + * + * @return the metric key, or {@code null} if none was resolved + */ + public String getEvaluationMetricKey() { + return evaluationMetricKey; + } + + /** + * Creates a new builder. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link AIConfigFlagValue}. + */ + public static final class Builder { + private Boolean enabled; + private String variationKey; + private Integer version; + private AIConfigMode mode; + private ModelConfig model; + private ProviderConfig provider; + private List messages; + private String instructions; + private Map tools; + private JudgeConfiguration judgeConfiguration; + private String evaluationMetricKey; + + private Builder() { + } + + /** + * Sets the enabled flag. + * + * @param v the enabled flag + * @return this builder + */ + public Builder enabled(Boolean v) { + this.enabled = v; + return this; + } + + /** + * Sets the variation key. + * + * @param v the variation key + * @return this builder + */ + public Builder variationKey(String v) { + this.variationKey = v; + return this; + } + + /** + * Sets the version. + * + * @param v the version + * @return this builder + */ + public Builder version(Integer v) { + this.version = v; + return this; + } + + /** + * Sets the mode. + * + * @param v the mode + * @return this builder + */ + public Builder mode(AIConfigMode v) { + this.mode = v; + return this; + } + + /** + * Sets the model configuration. + * + * @param v the model + * @return this builder + */ + public Builder model(ModelConfig v) { + this.model = v; + return this; + } + + /** + * Sets the provider configuration. + * + * @param v the provider + * @return this builder + */ + public Builder provider(ProviderConfig v) { + this.provider = v; + return this; + } + + /** + * Sets the prompt messages. + * + * @param v the messages + * @return this builder + */ + public Builder messages(List v) { + this.messages = v; + return this; + } + + /** + * Sets the agent instructions. + * + * @param v the instructions + * @return this builder + */ + public Builder instructions(String v) { + this.instructions = v; + return this; + } + + /** + * Sets the resolved tools map. + * + * @param v the tools + * @return this builder + */ + public Builder tools(Map v) { + this.tools = v; + return this; + } + + /** + * Sets the judge configuration. + * + * @param v the judge configuration + * @return this builder + */ + public Builder judgeConfiguration(JudgeConfiguration v) { + this.judgeConfiguration = v; + return this; + } + + /** + * Sets the resolved evaluation metric key. + * + * @param v the evaluation metric key + * @return this builder + */ + public Builder evaluationMetricKey(String v) { + this.evaluationMetricKey = v; + return this; + } + + /** + * Builds the immutable {@link AIConfigFlagValue}. + * + * @return a new {@link AIConfigFlagValue} + */ + public AIConfigFlagValue build() { + return new AIConfigFlagValue(this); + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java new file mode 100644 index 00000000..d5e07cf6 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -0,0 +1,251 @@ +package com.launchdarkly.sdk.server.ai.internal; + +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.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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Parses an {@link LDValue} flag variation into the strongly-typed {@link AIConfigFlagValue}. + *

+ * Parsing is intentionally defensive: malformed, missing, or wrong-typed fields never raise an + * exception. Instead, individual fields fall back to {@code null} (or are skipped, for list/map + * entries), so a corrupt payload degrades to a safe, disabled-looking configuration rather than + * failing the caller's AI request. This mirrors the lenient decoding of the JS and Python SDKs. + *

+ * This class is an internal implementation detail and is not part of the supported API. + */ +public final class AIConfigParser { + private AIConfigParser() { + } + + /** + * Parses a flag variation value. + * + * @param value the raw flag value; may be {@code null} or any JSON type + * @return the parsed representation; never {@code null} (an empty value yields an all-absent + * result) + */ + public static AIConfigFlagValue parse(LDValue value) { + AIConfigFlagValue.Builder builder = AIConfigFlagValue.builder(); + if (value == null || value.getType() != LDValueType.OBJECT) { + return builder.build(); + } + + parseMeta(value.get("_ldMeta"), builder); + builder.model(parseModel(value.get("model"))); + builder.provider(parseProvider(value.get("provider"))); + builder.messages(parseMessages(value.get("messages"))); + builder.instructions(asStringOrNull(value.get("instructions"))); + builder.tools(resolveTools(value)); + builder.judgeConfiguration(parseJudgeConfiguration(value.get("judgeConfiguration"))); + builder.evaluationMetricKey(resolveEvaluationMetricKey(value)); + + return builder.build(); + } + + private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { + if (meta == null || meta.getType() != LDValueType.OBJECT) { + return; + } + LDValue enabled = meta.get("enabled"); + if (enabled.getType() == LDValueType.BOOLEAN) { + builder.enabled(enabled.booleanValue()); + } + builder.variationKey(asStringOrNull(meta.get("variationKey"))); + LDValue version = meta.get("version"); + if (version.getType() == LDValueType.NUMBER) { + builder.version(version.intValue()); + } + builder.mode(AIConfigMode.fromWireValue(asStringOrNull(meta.get("mode")))); + } + + /** + * Parses a {@code model} object. + * + * @param model the model value + * @return a {@link ModelConfig}, or {@code null} if the value is not an object + */ + static ModelConfig parseModel(LDValue model) { + if (model == null || model.getType() != LDValueType.OBJECT) { + return null; + } + return ModelConfig.builder(asStringOrNull(model.get("name"))) + .parameters(LDValueConverter.toMap(model.get("parameters"))) + .custom(LDValueConverter.toMap(model.get("custom"))) + .build(); + } + + /** + * Parses a {@code provider} object. + * + * @param provider the provider value + * @return a {@link ProviderConfig}, or {@code null} if the value is not an object + */ + static ProviderConfig parseProvider(LDValue provider) { + if (provider == null || provider.getType() != LDValueType.OBJECT) { + return null; + } + return new ProviderConfig(asStringOrNull(provider.get("name"))); + } + + /** + * Parses a {@code messages} array. Entries missing a recognized role or a string {@code content} + * are skipped. + * + * @param messages the messages value + * @return a list of {@link LDMessage}, or {@code null} if the value is not an array + */ + static List parseMessages(LDValue messages) { + if (messages == null || messages.getType() != LDValueType.ARRAY) { + return null; + } + List result = new ArrayList<>(messages.size()); + for (LDValue entry : messages.values()) { + if (entry == null || entry.getType() != LDValueType.OBJECT) { + continue; + } + LDMessage.Role role = LDMessage.Role.fromWireValue(asStringOrNull(entry.get("role"))); + LDValue content = entry.get("content"); + if (role == null || content.getType() != LDValueType.STRING) { + continue; + } + result.add(new LDMessage(role, content.stringValue())); + } + return result; + } + + /** + * Resolves the root-level tools map. Prefers the top-level {@code tools} object; otherwise falls + * back to deriving entries from {@code model.parameters.tools[]}. + * + * @param flagValue the full flag value object + * @return a map keyed by tool name, or {@code null} when no tools are present + */ + static Map resolveTools(LDValue flagValue) { + LDValue tools = flagValue.get("tools"); + if (tools.getType() == LDValueType.OBJECT) { + Map result = new LinkedHashMap<>(); + for (String name : tools.keys()) { + ToolConfig tool = parseTool(name, tools.get(name)); + if (tool != null) { + result.put(name, tool); + } + } + return result.isEmpty() ? null : result; + } + + LDValue rawTools = flagValue.get("model").get("parameters").get("tools"); + if (rawTools.getType() != LDValueType.ARRAY) { + return null; + } + Map result = new LinkedHashMap<>(); + for (LDValue entry : rawTools.values()) { + String name = asStringOrNull(entry.get("name")); + if (name == null) { + continue; + } + ToolConfig tool = parseTool(name, entry); + if (tool != null) { + result.put(name, tool); + } + } + return result.isEmpty() ? null : result; + } + + private static ToolConfig parseTool(String fallbackName, LDValue tool) { + if (tool == null || tool.getType() != LDValueType.OBJECT) { + return null; + } + String name = asStringOrNull(tool.get("name")); + if (name == null) { + name = fallbackName; + } + return ToolConfig.builder(name) + .description(asStringOrNull(tool.get("description"))) + .type(asStringOrNull(tool.get("type"))) + .parameters(LDValueConverter.toMap(tool.get("parameters"))) + .customParameters(LDValueConverter.toMap(tool.get("customParameters"))) + .build(); + } + + /** + * Parses a {@code judgeConfiguration} object. Judge entries missing a string {@code key} are + * skipped; a missing or non-numeric {@code samplingRate} defaults to {@code 0.0}. + * + * @param judgeConfig the judge configuration value + * @return a {@link JudgeConfiguration}, or {@code null} if the value is not an object + */ + static JudgeConfiguration parseJudgeConfiguration(LDValue judgeConfig) { + if (judgeConfig == null || judgeConfig.getType() != LDValueType.OBJECT) { + return null; + } + LDValue judges = judgeConfig.get("judges"); + List result = new ArrayList<>(); + if (judges.getType() == LDValueType.ARRAY) { + for (LDValue entry : judges.values()) { + if (entry == null || entry.getType() != LDValueType.OBJECT) { + continue; + } + String key = asStringOrNull(entry.get("key")); + if (key == null) { + continue; + } + LDValue rate = entry.get("samplingRate"); + double samplingRate = rate.getType() == LDValueType.NUMBER ? rate.doubleValue() : 0.0; + result.add(new JudgeConfiguration.Judge(key, samplingRate)); + } + } + return new JudgeConfiguration(result); + } + + /** + * Resolves the evaluation metric key, preferring the scalar {@code evaluationMetricKey} and + * falling back to the first non-blank entry of {@code evaluationMetricKeys[]}. + * + * @param flagValue the full flag value object + * @return the resolved metric key, or {@code null} if none is present + */ + static String resolveEvaluationMetricKey(LDValue flagValue) { + LDValue single = flagValue.get("evaluationMetricKey"); + if (single.getType() == LDValueType.STRING) { + String trimmed = trimToNull(single.stringValue()); + if (trimmed != null) { + return trimmed; + } + } + LDValue many = flagValue.get("evaluationMetricKeys"); + if (many.getType() == LDValueType.ARRAY) { + for (LDValue entry : many.values()) { + if (entry.getType() == LDValueType.STRING) { + String trimmed = trimToNull(entry.stringValue()); + if (trimmed != null) { + return trimmed; + } + } + } + } + return null; + } + + private static String asStringOrNull(LDValue value) { + return value != null && value.getType() == LDValueType.STRING ? value.stringValue() : null; + } + + private static String trimToNull(String s) { + if (s == null) { + return null; + } + String trimmed = s.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} 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..54861a9c --- /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.samskivert.mustache.Mustache; +import com.samskivert.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/LDValueConverter.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java new file mode 100644 index 00000000..249b2eed --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java @@ -0,0 +1,116 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Converts an {@link LDValue} tree into a tree of plain Java values + * ({@link String}, {@link Long}, {@link Double}, {@link Boolean}, {@link List}, {@link Map}, or + * {@code null}). + *

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

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

+ * This class is an internal implementation detail and is not part of the supported API. + */ +public final class LDValueConverter { + /** + * Maximum nesting depth converted before deeper values are dropped. + */ + public static final int MAX_DEPTH = 100; + + /** + * Largest magnitude of a whole number that a {@code double} can represent exactly. + */ + private static final double MAX_EXACT_INTEGER = 9007199254740992.0; // 2^53 + + private LDValueConverter() { + } + + /** + * Converts an {@link LDValue} to a plain Java value. + * + * @param value the value to convert; may be {@code null} + * @return the converted value, or {@code null} if the input is {@code null} or JSON null + */ + public static Object toJavaObject(LDValue value) { + return convert(value, 0); + } + + /** + * Converts an {@link LDValue} object to an unmodifiable {@code Map}. + * + * @param value the value to convert + * @return the converted map; {@code null} if {@code value} is not a JSON object + */ + public static Map toMap(LDValue value) { + if (value == null || value.getType() != LDValueType.OBJECT) { + return null; + } + Object converted = convert(value, 0); + if (converted instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) converted; + return map; + } + return null; + } + + private static Object convert(LDValue value, int depth) { + if (value == null || value.isNull()) { + return null; + } + if (depth >= MAX_DEPTH) { + return null; + } + + LDValueType type = value.getType(); + switch (type) { + case BOOLEAN: + return value.booleanValue(); + case NUMBER: + return convertNumber(value.doubleValue()); + case STRING: + return value.stringValue(); + case ARRAY: { + List list = new ArrayList<>(value.size()); + for (LDValue element : value.values()) { + list.add(convert(element, depth + 1)); + } + return Collections.unmodifiableList(list); + } + case OBJECT: { + // LinkedHashMap to preserve field order for deterministic output. + Map map = new LinkedHashMap<>(); + for (String key : value.keys()) { + map.put(key, convert(value.get(key), depth + 1)); + } + return Collections.unmodifiableMap(map); + } + case NULL: + default: + return null; + } + } + + private static Object convertNumber(double d) { + if (!Double.isNaN(d) && !Double.isInfinite(d) + && d == Math.rint(d) && Math.abs(d) <= MAX_EXACT_INTEGER) { + return (long) d; + } + return d; + } +} diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java new file mode 100644 index 00000000..c4af388d --- /dev/null +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java @@ -0,0 +1,178 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; + +import com.launchdarkly.sdk.LDValue; +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 java.util.List; + +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class AIConfigParserTest { + @Test + public void parsesFullCompletionConfig() { + LDValue value = LDValue.parse("{" + + "\"_ldMeta\":{\"variationKey\":\"v1\",\"enabled\":true,\"version\":3,\"mode\":\"completion\"}," + + "\"model\":{\"name\":\"gpt-4\",\"parameters\":{\"temperature\":0.7,\"maxTokens\":100}," + + "\"custom\":{\"team\":\"core\"}}," + + "\"provider\":{\"name\":\"openai\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"You are {{persona}}.\"}," + + "{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"judgeConfiguration\":{\"judges\":[{\"key\":\"j1\",\"samplingRate\":0.5}]}," + + "\"tools\":{\"search\":{\"name\":\"search\",\"description\":\"d\",\"type\":\"function\"," + + "\"parameters\":{\"q\":\"string\"}}}" + + "}"); + + AIConfigFlagValue parsed = AIConfigParser.parse(value); + + assertThat(parsed.isEnabled(), is(true)); + assertThat(parsed.getVariationKey(), is("v1")); + assertThat(parsed.getVersion(), is(3)); + assertThat(parsed.getMode(), is(AIConfigMode.COMPLETION)); + assertThat(parsed.getModel().getName(), is("gpt-4")); + assertThat(parsed.getModel().getParameter("temperature"), is((Object) 0.7)); + assertThat(parsed.getModel().getParameter("maxTokens"), is((Object) 100L)); + assertThat(parsed.getModel().getCustom("team"), is((Object) "core")); + assertThat(parsed.getProvider().getName(), is("openai")); + assertThat(parsed.getMessages(), hasSize(2)); + assertThat(parsed.getMessages().get(0).getRole(), is(LDMessage.Role.SYSTEM)); + JudgeConfiguration judges = parsed.getJudgeConfiguration(); + assertThat(judges.getJudges(), hasSize(1)); + assertThat(judges.getJudges().get(0).getKey(), is("j1")); + assertThat(judges.getJudges().get(0).getSamplingRate(), is(0.5)); + assertThat(parsed.getTools().keySet(), contains("search")); + assertThat(parsed.getTools().get("search").getType(), is("function")); + } + + @Test + public void parsesAgentInstructions() { + LDValue value = LDValue.parse("{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"Help {{name}}\"}"); + AIConfigFlagValue parsed = AIConfigParser.parse(value); + assertThat(parsed.getMode(), is(AIConfigMode.AGENT)); + assertThat(parsed.getInstructions(), is("Help {{name}}")); + assertThat(parsed.getMessages(), is(nullValue())); + } + + @Test + public void nullValueYieldsAllAbsentDisabled() { + AIConfigFlagValue parsed = AIConfigParser.parse(LDValue.ofNull()); + assertThat(parsed.isEnabled(), is(false)); + assertThat(parsed.getEnabled(), is(nullValue())); + assertThat(parsed.getMode(), is(nullValue())); + assertThat(parsed.getModel(), is(nullValue())); + assertThat(parsed.getMessages(), is(nullValue())); + } + + @Test + public void nonObjectValueIsSafe() { + assertThat(AIConfigParser.parse(LDValue.of("a string")).isEnabled(), is(false)); + assertThat(AIConfigParser.parse(LDValue.parse("[1,2]")).isEnabled(), is(false)); + } + + @Test + public void skipsMalformedMessagesButKeepsValidOnes() { + LDValue value = LDValue.parse("{\"messages\":[" + + "{\"role\":\"system\"}," // missing content + + "{\"content\":\"orphan\"}," // missing role + + "{\"role\":\"bogus\",\"content\":\"x\"}," // unknown role + + "{\"role\":\"user\",\"content\":123}," // non-string content + + "{\"role\":\"assistant\",\"content\":\"valid\"}" + + "]}"); + List messages = AIConfigParser.parse(value).getMessages(); + assertThat(messages, hasSize(1)); + assertThat(messages.get(0).getRole(), is(LDMessage.Role.ASSISTANT)); + assertThat(messages.get(0).getContent(), is("valid")); + } + + @Test + public void messagesFieldOfWrongTypeYieldsNull() { + AIConfigFlagValue parsed = AIConfigParser.parse(LDValue.parse("{\"messages\":{\"not\":\"array\"}}")); + assertThat(parsed.getMessages(), is(nullValue())); + } + + @Test + public void unknownModeResolvesToNull() { + AIConfigFlagValue parsed = AIConfigParser.parse(LDValue.parse("{\"_ldMeta\":{\"mode\":\"weird\"}}")); + assertThat(parsed.getMode(), is(nullValue())); + } + + @Test + public void resolvesToolsFromModelParametersFallback() { + LDValue value = LDValue.parse("{\"model\":{\"name\":\"m\",\"parameters\":{\"tools\":[" + + "{\"name\":\"t1\",\"type\":\"function\"}," + + "{\"type\":\"function\"}" // no name -> skipped + + "]}}}"); + AIConfigFlagValue parsed = AIConfigParser.parse(value); + assertThat(parsed.getTools().keySet(), contains("t1")); + } + + @Test + public void rootLevelToolsTakePrecedenceOverModelParameters() { + LDValue value = LDValue.parse("{" + + "\"tools\":{\"root\":{\"name\":\"root\"}}," + + "\"model\":{\"parameters\":{\"tools\":[{\"name\":\"fromParams\"}]}}" + + "}"); + AIConfigFlagValue parsed = AIConfigParser.parse(value); + assertThat(parsed.getTools().keySet(), contains("root")); + } + + @Test + public void evaluationMetricKeyPrefersScalarTrimmed() { + AIConfigFlagValue parsed = AIConfigParser.parse( + LDValue.parse("{\"evaluationMetricKey\":\" primary \",\"evaluationMetricKeys\":[\"other\"]}")); + assertThat(parsed.getEvaluationMetricKey(), is("primary")); + } + + @Test + public void evaluationMetricKeyFallsBackToFirstNonBlankInList() { + AIConfigFlagValue parsed = AIConfigParser.parse( + LDValue.parse("{\"evaluationMetricKey\":\" \",\"evaluationMetricKeys\":[\"\",\" \",\"good\",\"later\"]}")); + assertThat(parsed.getEvaluationMetricKey(), is("good")); + } + + @Test + public void evaluationMetricKeyAbsentYieldsNull() { + assertThat(AIConfigParser.parse(LDValue.parse("{}")).getEvaluationMetricKey(), is(nullValue())); + } + + @Test + public void judgeEntriesMissingKeyAreSkippedAndSamplingRateDefaultsToZero() { + LDValue value = LDValue.parse("{\"judgeConfiguration\":{\"judges\":[" + + "{\"samplingRate\":0.9}," // missing key -> skipped + + "{\"key\":\"j-no-rate\"}" // missing samplingRate -> 0.0 + + "]}}"); + JudgeConfiguration judges = AIConfigParser.parse(value).getJudgeConfiguration(); + assertThat(judges.getJudges(), hasSize(1)); + assertThat(judges.getJudges().get(0).getKey(), is("j-no-rate")); + assertThat(judges.getJudges().get(0).getSamplingRate(), is(0.0)); + } + + @Test + public void enabledFalseIsDistinctFromAbsent() { + AIConfigFlagValue explicitFalse = + AIConfigParser.parse(LDValue.parse("{\"_ldMeta\":{\"enabled\":false}}")); + assertThat(explicitFalse.getEnabled(), is(false)); + assertThat(explicitFalse.isEnabled(), is(false)); + + AIConfigFlagValue absent = AIConfigParser.parse(LDValue.parse("{\"_ldMeta\":{}}")); + assertThat(absent.getEnabled(), is(nullValue())); + assertThat(absent.isEnabled(), is(false)); + } + + @Test + public void judgeConfigurationWithNoJudgesIsEmptyNotNull() { + AIConfigFlagValue parsed = + AIConfigParser.parse(LDValue.parse("{\"judgeConfiguration\":{}}")); + assertThat(parsed.getJudgeConfiguration().getJudges(), is(empty())); + } +} 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")); + } +} 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 new file mode 100644 index 00000000..17872c97 --- /dev/null +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java @@ -0,0 +1,73 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import com.launchdarkly.sdk.LDValue; + +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class LDValueConverterTest { + @Test + public void nullAndJsonNullConvertToNull() { + assertThat(LDValueConverter.toJavaObject(null), is(nullValue())); + assertThat(LDValueConverter.toJavaObject(LDValue.ofNull()), is(nullValue())); + } + + @Test + public void integralNumberBecomesLong() { + Object converted = LDValueConverter.toJavaObject(LDValue.of(100)); + assertThat(converted, instanceOf(Long.class)); + assertThat(converted, is((Object) 100L)); + } + + @Test + public void fractionalNumberBecomesDouble() { + Object converted = LDValueConverter.toJavaObject(LDValue.of(0.75)); + assertThat(converted, instanceOf(Double.class)); + assertThat(converted, is((Object) 0.75)); + } + + @Test + public void stringAndBooleanConvertDirectly() { + assertThat(LDValueConverter.toJavaObject(LDValue.of("hi")), is((Object) "hi")); + assertThat(LDValueConverter.toJavaObject(LDValue.of(true)), is((Object) Boolean.TRUE)); + } + + @Test + public void nestedObjectAndArrayConvert() { + LDValue value = LDValue.parse("{\"a\":1,\"b\":[\"x\",2],\"c\":{\"d\":true}}"); + Map map = LDValueConverter.toMap(value); + assertThat(map.get("a"), is((Object) 1L)); + assertThat(((List) map.get("b")).get(0), is((Object) "x")); + assertThat(((List) map.get("b")).get(1), is((Object) 2L)); + assertThat(((Map) map.get("c")).get("d"), is((Object) Boolean.TRUE)); + } + + @Test + public void toMapReturnsNullForNonObject() { + assertThat(LDValueConverter.toMap(LDValue.of("not-an-object")), is(nullValue())); + assertThat(LDValueConverter.toMap(LDValue.parse("[1,2,3]")), is(nullValue())); + } + + @Test + public void deeplyNestedInputDoesNotOverflowAndIsCapped() { + int depth = LDValueConverter.MAX_DEPTH + 50; + StringBuilder json = new StringBuilder(); + for (int i = 0; i < depth; i++) { + json.append('['); + } + for (int i = 0; i < depth; i++) { + json.append(']'); + } + // Should neither throw nor StackOverflow; the top level is still a List. + Object converted = LDValueConverter.toJavaObject(LDValue.parse(json.toString())); + assertThat(converted, instanceOf(List.class)); + } +} From 11043abaabe6f875e96ab95e6c0f20ca7cea5580 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 5 Jun 2026 12:21:40 -0400 Subject: [PATCH 2/5] fix: pin jmustache to 1.15 for Java 8 compatibility (AIC-2662) jmustache 1.16 is compiled for Java 9 (class file version 53.0) and throws UnsupportedClassVersionError on the Java 8 runtimes this SDK supports, which surfaced as JDK 8 test failures in CI. 1.15 is the last release compiled for Java (bytecode major 51) and exposes the same compiler API we use (escapeHTML / defaultValue), with no transitive dependencies. Co-authored-by: Cursor --- lib/sdk/server-ai/build.gradle | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index c1016eff..2c0a1a76 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -46,11 +46,14 @@ ext.versions = [ // appears in this library's public signature, so it is exposed as an `api` dependency. "sdk": "7.14.0", // jmustache: Mustache engine for AI Config message/instruction interpolation. - // Audit (AIC-2662): com.samskivert:jmustache 1.16 is BSD 2-Clause licensed, actively - // maintained, and ships as a single self-contained jar with no transitive runtime - // dependencies (verified via the published POM). Chosen over mustache.java / Handlebars.java - // for its zero-dependency footprint; HTML escaping is disabled to match the JS/Python SDKs. - "jmustache": "1.16" + // Audit (AIC-2662): com.samskivert:jmustache is BSD 2-Clause licensed and ships as a single + // self-contained jar with no transitive runtime dependencies (verified via the published POM). + // Chosen over mustache.java / Handlebars.java for its zero-dependency footprint; HTML escaping + // is disabled to match the JS/Python SDKs. + // Pinned to 1.15 (NOT 1.16): 1.16 is compiled for Java 9 (class file 53.0) and throws + // UnsupportedClassVersionError on the Java 8 runtimes this SDK supports. 1.15 is the last + // release compiled for Java 8 and exposes the same compiler API we use. + "jmustache": "1.15" ] ext.libraries = [:] From 47ceb0f3be20f632af450dfa1ab1eab866648fa4 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 8 Jun 2026 11:41:22 -0400 Subject: [PATCH 3/5] chore: remove external Mustache dependency, defer interpolation to AIC-2695 (AIC-2662) Per the SDK team's supply-chain guidance we will not link the external com.samskivert:jmustache artifact. Drop the dependency and the Interpolator (plus its tests) from this PR; the Mustache engine will be vendored (source copied into an internal, relocated package) together with the Interpolator in AIC-2695, which gates the v1.0 release. This PR now ships only the data model and the defensive LDValue parsing layer, neither of which depends on a templating engine. Co-authored-by: Cursor --- lib/sdk/server-ai/build.gradle | 19 ++-- .../sdk/server/ai/internal/Interpolator.java | 97 ------------------- .../server/ai/internal/InterpolatorTest.java | 97 ------------------- 3 files changed, 6 insertions(+), 207 deletions(-) delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java delete mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index 2c0a1a76..1a3acfec 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -44,16 +44,12 @@ ext { 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", - // jmustache: Mustache engine for AI Config message/instruction interpolation. - // Audit (AIC-2662): com.samskivert:jmustache is BSD 2-Clause licensed and ships as a single - // self-contained jar with no transitive runtime dependencies (verified via the published POM). - // Chosen over mustache.java / Handlebars.java for its zero-dependency footprint; HTML escaping - // is disabled to match the JS/Python SDKs. - // Pinned to 1.15 (NOT 1.16): 1.16 is compiled for Java 9 (class file 53.0) and throws - // UnsupportedClassVersionError on the Java 8 runtimes this SDK supports. 1.15 is the last - // release compiled for Java 8 and exposes the same compiler API we use. - "jmustache": "1.15" + "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). ] ext.libraries = [:] @@ -62,9 +58,6 @@ dependencies { // Exposed on the public API surface (LDClientInterface), therefore `api` not `implementation`. api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}" - // Mustache templating, kept as `implementation` so it is not leaked onto consumers' classpath. - implementation "com.samskivert:jmustache:${versions.jmustache}" - testImplementation "org.hamcrest:hamcrest-all:1.3" testImplementation "junit:junit:4.13.2" testImplementation "org.mockito:mockito-core:3.12.4" 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 deleted file mode 100644 index 54861a9c..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java +++ /dev/null @@ -1,97 +0,0 @@ -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.samskivert.mustache.Mustache; -import com.samskivert.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: - *
    - *
  • No HTML escaping. The escape function is the identity, so {@code {{x}}} and - * {@code {{{x}}}} render identically and values are emitted verbatim.
  • - *
  • Missing and null variables render as the empty string rather than throwing or - * leaving the placeholder in place.
  • - *
  • The reserved {@code ldctx} variable is derived from the evaluation context and is - * merged in last, so it always overrides any caller-supplied {@code ldctx}. Context - * attributes are addressable as {@code {{ldctx.key}}}, {@code {{ldctx.name}}}, and so on.
  • - *
- *

- * 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/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 deleted file mode 100644 index 88132fb9..00000000 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java +++ /dev/null @@ -1,97 +0,0 @@ -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 adb47c22499a25ebe54cbfffe8eea93c5e6fbc5c Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 8 Jun 2026 11:48:47 -0400 Subject: [PATCH 4/5] chore: drop empty sonatype credential placeholders from gradle.properties (AIC-2662) These empty sonatypeUsername/sonatypePassword placeholders aren't needed; the core SDK module (lib/sdk/server) carries none and publishes fine. The nexus publish plugin resolves credentials from the environment at release time. Co-authored-by: Cursor --- lib/sdk/server-ai/gradle.properties | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/sdk/server-ai/gradle.properties b/lib/sdk/server-ai/gradle.properties index 21bcaf39..248acf1e 100644 --- a/lib/sdk/server-ai/gradle.properties +++ b/lib/sdk/server-ai/gradle.properties @@ -1,8 +1,3 @@ #x-release-please-start-version version=0.1.0 #x-release-please-end - -# The following empty ossrh properties are used by LaunchDarkly's internal integration testing framework -# and should not be needed for typical development purposes (including by third-party developers). -sonatypeUsername= -sonatypePassword= From 69416f0c4868b697e3ce2d2f178be6562c339e38 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 10 Jun 2026 19:37:39 -0400 Subject: [PATCH 5/5] refactor: consolidate data-model types into LDAIConfigTypes (AIC-2662) Group the shared AI Config shapes (Mode, Message, Model, Provider, Tool, JudgeConfiguration) as nested types under a single non-instantiable LDAIConfigTypes container instead of separate top-level classes. This reduces the file count and frees up generic names like Message and Model within the datamodel package, matching the organization used by the .NET SDK (LdAiConfigTypes). Per Matt's review on #171. Internal references (AIConfigParser, AIConfigFlagValue) and tests are updated to the nested names; behavior is unchanged. Co-authored-by: Cursor --- .../sdk/server/ai/datamodel/AIConfigMode.java | 57 -- .../ai/datamodel/JudgeConfiguration.java | 121 --- .../server/ai/datamodel/LDAIConfigTypes.java | 703 ++++++++++++++++++ .../sdk/server/ai/datamodel/LDMessage.java | 130 ---- .../sdk/server/ai/datamodel/ModelConfig.java | 157 ---- .../server/ai/datamodel/ProviderConfig.java | 51 -- .../sdk/server/ai/datamodel/ToolConfig.java | 189 ----- .../server/ai/internal/AIConfigFlagValue.java | 52 +- .../server/ai/internal/AIConfigParser.java | 50 +- .../ai/internal/AIConfigParserTest.java | 16 +- 10 files changed, 762 insertions(+), 764 deletions(-) delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java delete mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java deleted file mode 100644 index 7752a56f..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/AIConfigMode.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.launchdarkly.sdk.server.ai.datamodel; - -/** - * The mode of an AI Config, as carried by the {@code _ldMeta.mode} field of a flag variation. - *

- * The mode determines which kind of configuration a variation represents and which retrieval - * method on the client it is valid for. - */ -public enum AIConfigMode { - /** - * A completion (chat/prompt) configuration. This is the default when no mode is present. - */ - COMPLETION("completion"), - - /** - * An agent configuration, which carries {@code instructions} instead of {@code messages}. - */ - AGENT("agent"), - - /** - * A judge configuration, used to evaluate the output of another configuration. - */ - JUDGE("judge"); - - private final String wireValue; - - AIConfigMode(String wireValue) { - this.wireValue = wireValue; - } - - /** - * Returns the string used to represent this mode in the JSON protocol. - * - * @return the wire representation (for example {@code "completion"}) - */ - public String getWireValue() { - return wireValue; - } - - /** - * Resolves a wire string to a mode. - * - * @param value the wire value, such as {@code "agent"}; may be {@code null} - * @return the matching mode, or {@code null} if the value is {@code null} or unrecognized - */ - public static AIConfigMode fromWireValue(String value) { - if (value == null) { - return null; - } - for (AIConfigMode mode : values()) { - if (mode.wireValue.equals(value)) { - return mode; - } - } - return null; - } -} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java deleted file mode 100644 index 57752ebd..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/JudgeConfiguration.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.launchdarkly.sdk.server.ai.datamodel; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -/** - * Configuration referencing the judges that may evaluate an AI Config. - *

- * This is parsed from the {@code judgeConfiguration} field of a flag variation and is visible on - * completion and agent configs. In v1.0 judges are invoked manually; the SDK does not auto-attach - * them. Instances are immutable. - */ -public final class JudgeConfiguration { - /** - * Configuration for a single judge attachment: which judge AI Config to use and how frequently - * to sample it. - *

- * Instances are immutable. - */ - public static final class Judge { - private final String key; - private final double samplingRate; - - /** - * Constructs a judge attachment. - * - * @param key the key of the judge AI Config; must not be {@code null} - * @param samplingRate the sampling rate, nominally in the range {@code 0.0}–{@code 1.0} - * @throws NullPointerException if {@code key} is {@code null} - */ - public Judge(String key, double samplingRate) { - this.key = Objects.requireNonNull(key, "key"); - this.samplingRate = samplingRate; - } - - /** - * Returns the key of the judge AI Config. - * - * @return the judge key, never {@code null} - */ - public String getKey() { - return key; - } - - /** - * Returns the configured sampling rate. - * - * @return the sampling rate - */ - public double getSamplingRate() { - return samplingRate; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof Judge)) { - return false; - } - Judge other = (Judge) o; - return Double.compare(samplingRate, other.samplingRate) == 0 && key.equals(other.key); - } - - @Override - public int hashCode() { - return Objects.hash(key, samplingRate); - } - - @Override - public String toString() { - return "Judge{key=" + key + ", samplingRate=" + samplingRate + '}'; - } - } - - private final List judges; - - /** - * Constructs a judge configuration. - * - * @param judges the judge attachments; may be {@code null}, treated as empty - */ - public JudgeConfiguration(List judges) { - this.judges = judges == null - ? Collections.emptyList() - : Collections.unmodifiableList(new ArrayList<>(judges)); - } - - /** - * Returns the configured judge attachments as an unmodifiable list. - * - * @return the judges; never {@code null} (empty when none were specified) - */ - public List getJudges() { - return judges; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof JudgeConfiguration)) { - return false; - } - return judges.equals(((JudgeConfiguration) o).judges); - } - - @Override - public int hashCode() { - return judges.hashCode(); - } - - @Override - public String toString() { - return "JudgeConfiguration{judges=" + judges + '}'; - } -} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java new file mode 100644 index 00000000..e6ccf122 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java @@ -0,0 +1,703 @@ +package com.launchdarkly.sdk.server.ai.datamodel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Container for the shared, immutable AI Config data-model types. + *

+ * These shapes ({@link Mode}, {@link Message}, {@link Model}, {@link Provider}, {@link Tool}, and + * {@link JudgeConfiguration}) are used across the completion, agent, and judge configs. They are + * grouped under this single type, rather than declared as separate top-level classes, to keep the + * package small and to free up generic names such as {@code Message} and {@code Model}. + *

+ * This class is not instantiable. + */ +public final class LDAIConfigTypes { + private LDAIConfigTypes() { + } + + /** + * The mode of an AI Config, as carried by the {@code _ldMeta.mode} field of a flag variation. + *

+ * The mode determines which kind of configuration a variation represents and which retrieval + * method on the client it is valid for. + */ + public enum Mode { + /** + * A completion (chat/prompt) configuration. This is the default when no mode is present. + */ + COMPLETION("completion"), + + /** + * An agent configuration, which carries {@code instructions} instead of {@code messages}. + */ + AGENT("agent"), + + /** + * A judge configuration, used to evaluate the output of another configuration. + */ + JUDGE("judge"); + + private final String wireValue; + + Mode(String wireValue) { + this.wireValue = wireValue; + } + + /** + * Returns the string used to represent this mode in the JSON protocol. + * + * @return the wire representation (for example {@code "completion"}) + */ + public String getWireValue() { + return wireValue; + } + + /** + * Resolves a wire string to a mode. + * + * @param value the wire value, such as {@code "agent"}; may be {@code null} + * @return the matching mode, or {@code null} if the value is {@code null} or unrecognized + */ + public static Mode fromWireValue(String value) { + if (value == null) { + return null; + } + for (Mode mode : values()) { + if (mode.wireValue.equals(value)) { + return mode; + } + } + return null; + } + } + + /** + * A single prompt message in an AI Config, consisting of a {@link Role} and string content. + *

+ * Instances are immutable. + */ + public static final class Message { + /** + * The role of a {@link Message}. + */ + public enum Role { + /** + * A system message, typically used to set behavior or context. + */ + SYSTEM("system"), + + /** + * A message authored by the end user. + */ + USER("user"), + + /** + * A message authored by the assistant (model). + */ + ASSISTANT("assistant"); + + private final String wireValue; + + Role(String wireValue) { + this.wireValue = wireValue; + } + + /** + * Returns the string used to represent this role in the JSON protocol. + * + * @return the wire representation (for example {@code "system"}) + */ + public String getWireValue() { + return wireValue; + } + + /** + * Resolves a wire string to a role. + * + * @param value the wire value, such as {@code "user"}; may be {@code null} + * @return the matching role, or {@code null} if the value is {@code null} or unrecognized + */ + public static Role fromWireValue(String value) { + if (value == null) { + return null; + } + for (Role role : values()) { + if (role.wireValue.equals(value)) { + return role; + } + } + return null; + } + } + + private final Role role; + private final String content; + + /** + * Constructs a message. + * + * @param role the role of the message; must not be {@code null} + * @param content the message content; must not be {@code null} + * @throws NullPointerException if {@code role} or {@code content} is {@code null} + */ + public Message(Role role, String content) { + this.role = Objects.requireNonNull(role, "role"); + this.content = Objects.requireNonNull(content, "content"); + } + + /** + * Returns the role of this message. + * + * @return the role, never {@code null} + */ + public Role getRole() { + return role; + } + + /** + * Returns the content of this message. + * + * @return the content, never {@code null} + */ + public String getContent() { + return content; + } + + /** + * Returns a copy of this message with the given content, preserving the role. + *

+ * Used by the interpolation layer to produce a rendered message without mutating the original. + * + * @param newContent the replacement content; must not be {@code null} + * @return a new {@link Message} + */ + public Message withContent(String newContent) { + return new Message(role, newContent); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Message)) { + return false; + } + Message other = (Message) o; + return role == other.role && content.equals(other.content); + } + + @Override + public int hashCode() { + return Objects.hash(role, content); + } + + @Override + public String toString() { + return "Message{role=" + role + ", content=" + content + '}'; + } + } + + /** + * Configuration describing the model an AI Config should use. + *

+ * Instances are immutable. The {@code parameters} and {@code custom} maps hold arbitrary values + * decoded from the JSON protocol; their values are plain Java types ({@link String}, + * {@link Double}, {@link Boolean}, {@link java.util.List}, {@link java.util.Map}, or {@code null}). + * Build instances with {@link #builder(String)}. + */ + public static final class Model { + private final String name; + private final Map parameters; + private final Map custom; + + private Model(String name, Map parameters, Map custom) { + this.name = name; + this.parameters = parameters; + this.custom = custom; + } + + /** + * Returns the model name (for example {@code "gpt-4"}). + * + * @return the model name, or {@code null} if none was specified + */ + public String getName() { + return name; + } + + /** + * Returns the model-specific parameters as an unmodifiable map. + * + * @return the parameters; never {@code null} (empty when none were specified) + */ + public Map getParameters() { + return parameters; + } + + /** + * Returns customer-provided custom data as an unmodifiable map. + * + * @return the custom data; never {@code null} (empty when none was specified) + */ + public Map getCustom() { + return custom; + } + + /** + * Retrieves a single model parameter by key. + * + * @param key the parameter name + * @return the value, or {@code null} if absent + */ + public Object getParameter(String key) { + return parameters.get(key); + } + + /** + * Retrieves a single custom-data entry by key. + * + * @param key the custom-data name + * @return the value, or {@code null} if absent + */ + public Object getCustom(String key) { + return custom.get(key); + } + + /** + * Creates a builder for a model with the given name. + * + * @param name the model name + * @return a new {@link Builder} + */ + public static Builder builder(String name) { + return new Builder(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Model)) { + return false; + } + Model other = (Model) o; + return Objects.equals(name, other.name) + && parameters.equals(other.parameters) + && custom.equals(other.custom); + } + + @Override + public int hashCode() { + return Objects.hash(name, parameters, custom); + } + + @Override + public String toString() { + return "Model{name=" + name + ", parameters=" + parameters + ", custom=" + custom + '}'; + } + + /** + * Builder for {@link Model}. + */ + public static final class Builder { + private final String name; + private Map parameters; + private Map custom; + + private Builder(String name) { + this.name = name; + } + + /** + * Sets the model-specific parameters. The map is copied defensively. + * + * @param parameters the parameters; may be {@code null} + * @return this builder + */ + public Builder parameters(Map parameters) { + this.parameters = parameters == null ? null : new HashMap<>(parameters); + return this; + } + + /** + * Sets customer-provided custom data. The map is copied defensively. + * + * @param custom the custom data; may be {@code null} + * @return this builder + */ + public Builder custom(Map custom) { + this.custom = custom == null ? null : new HashMap<>(custom); + return this; + } + + /** + * Builds the immutable {@link Model}. + * + * @return a new {@link Model} + */ + public Model build() { + Map params = parameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(parameters)); + Map cust = custom == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(custom)); + return new Model(name, params, cust); + } + } + } + + /** + * Configuration describing the provider an AI Config should use. + *

+ * Instances are immutable. + */ + public static final class Provider { + private final String name; + + /** + * Constructs a provider configuration. + * + * @param name the provider name (for example {@code "openai"}); may be {@code null} + */ + public Provider(String name) { + this.name = name; + } + + /** + * Returns the provider name. + * + * @return the provider name, or {@code null} if none was specified + */ + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Provider)) { + return false; + } + return Objects.equals(name, ((Provider) o).name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } + + @Override + public String toString() { + return "Provider{name=" + name + '}'; + } + } + + /** + * A single entry from the root-level {@code tools} map of an AI Config flag variation. + *

+ * This is distinct from {@code model.parameters.tools[]}, which is the raw array passed through to + * LLM providers. Instances are immutable; build them with {@link #builder(String)}. + */ + public static final class Tool { + private final String name; + private final String description; + private final String type; + private final Map parameters; + private final Map customParameters; + + private Tool( + String name, + String description, + String type, + Map parameters, + Map customParameters) { + this.name = name; + this.description = description; + this.type = type; + this.parameters = parameters; + this.customParameters = customParameters; + } + + /** + * Returns the tool name. + * + * @return the tool name, or {@code null} if none was specified + */ + public String getName() { + return name; + } + + /** + * Returns the tool description. + * + * @return the description, or {@code null} if none was specified + */ + public String getDescription() { + return description; + } + + /** + * Returns the tool type. + * + * @return the type, or {@code null} if none was specified + */ + public String getType() { + return type; + } + + /** + * Returns the tool parameters as an unmodifiable map. + * + * @return the parameters; never {@code null} (empty when none were specified) + */ + public Map getParameters() { + return parameters; + } + + /** + * Returns the tool custom parameters as an unmodifiable map. + * + * @return the custom parameters; never {@code null} (empty when none were specified) + */ + public Map getCustomParameters() { + return customParameters; + } + + /** + * Creates a builder for a tool with the given name. + * + * @param name the tool name + * @return a new {@link Builder} + */ + public static Builder builder(String name) { + return new Builder(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Tool)) { + return false; + } + Tool other = (Tool) o; + return Objects.equals(name, other.name) + && Objects.equals(description, other.description) + && Objects.equals(type, other.type) + && parameters.equals(other.parameters) + && customParameters.equals(other.customParameters); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, type, parameters, customParameters); + } + + @Override + public String toString() { + return "Tool{name=" + name + ", description=" + description + ", type=" + type + + ", parameters=" + parameters + ", customParameters=" + customParameters + '}'; + } + + /** + * Builder for {@link Tool}. + */ + public static final class Builder { + private final String name; + private String description; + private String type; + private Map parameters; + private Map customParameters; + + private Builder(String name) { + this.name = name; + } + + /** + * Sets the tool description. + * + * @param description the description; may be {@code null} + * @return this builder + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * Sets the tool type. + * + * @param type the type; may be {@code null} + * @return this builder + */ + public Builder type(String type) { + this.type = type; + return this; + } + + /** + * Sets the tool parameters. The map is copied defensively. + * + * @param parameters the parameters; may be {@code null} + * @return this builder + */ + public Builder parameters(Map parameters) { + this.parameters = parameters == null ? null : new HashMap<>(parameters); + return this; + } + + /** + * Sets the tool custom parameters. The map is copied defensively. + * + * @param customParameters the custom parameters; may be {@code null} + * @return this builder + */ + public Builder customParameters(Map customParameters) { + this.customParameters = customParameters == null ? null : new HashMap<>(customParameters); + return this; + } + + /** + * Builds the immutable {@link Tool}. + * + * @return a new {@link Tool} + */ + public Tool build() { + Map params = parameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(parameters)); + Map customParams = customParameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(customParameters)); + return new Tool(name, description, type, params, customParams); + } + } + } + + /** + * Configuration referencing the judges that may evaluate an AI Config. + *

+ * This is parsed from the {@code judgeConfiguration} field of a flag variation and is visible on + * completion and agent configs. In v1.0 judges are invoked manually; the SDK does not auto-attach + * them. Instances are immutable. + */ + public static final class JudgeConfiguration { + /** + * Configuration for a single judge attachment: which judge AI Config to use and how frequently + * to sample it. + *

+ * Instances are immutable. + */ + public static final class Judge { + private final String key; + private final double samplingRate; + + /** + * Constructs a judge attachment. + * + * @param key the key of the judge AI Config; must not be {@code null} + * @param samplingRate the sampling rate, nominally in the range {@code 0.0}–{@code 1.0} + * @throws NullPointerException if {@code key} is {@code null} + */ + public Judge(String key, double samplingRate) { + this.key = Objects.requireNonNull(key, "key"); + this.samplingRate = samplingRate; + } + + /** + * Returns the key of the judge AI Config. + * + * @return the judge key, never {@code null} + */ + public String getKey() { + return key; + } + + /** + * Returns the configured sampling rate. + * + * @return the sampling rate + */ + public double getSamplingRate() { + return samplingRate; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Judge)) { + return false; + } + Judge other = (Judge) o; + return Double.compare(samplingRate, other.samplingRate) == 0 && key.equals(other.key); + } + + @Override + public int hashCode() { + return Objects.hash(key, samplingRate); + } + + @Override + public String toString() { + return "Judge{key=" + key + ", samplingRate=" + samplingRate + '}'; + } + } + + private final List judges; + + /** + * Constructs a judge configuration. + * + * @param judges the judge attachments; may be {@code null}, treated as empty + */ + public JudgeConfiguration(List judges) { + this.judges = judges == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(judges)); + } + + /** + * Returns the configured judge attachments as an unmodifiable list. + * + * @return the judges; never {@code null} (empty when none were specified) + */ + public List getJudges() { + return judges; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof JudgeConfiguration)) { + return false; + } + return judges.equals(((JudgeConfiguration) o).judges); + } + + @Override + public int hashCode() { + return judges.hashCode(); + } + + @Override + public String toString() { + return "JudgeConfiguration{judges=" + judges + '}'; + } + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java deleted file mode 100644 index 2eeeeed0..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDMessage.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.launchdarkly.sdk.server.ai.datamodel; - -import java.util.Objects; - -/** - * A single prompt message in an AI Config, consisting of a {@link Role} and string content. - *

- * Instances are immutable. - */ -public final class LDMessage { - /** - * The role of a {@link LDMessage}. - */ - public enum Role { - /** - * A system message, typically used to set behavior or context. - */ - SYSTEM("system"), - - /** - * A message authored by the end user. - */ - USER("user"), - - /** - * A message authored by the assistant (model). - */ - ASSISTANT("assistant"); - - private final String wireValue; - - Role(String wireValue) { - this.wireValue = wireValue; - } - - /** - * Returns the string used to represent this role in the JSON protocol. - * - * @return the wire representation (for example {@code "system"}) - */ - public String getWireValue() { - return wireValue; - } - - /** - * Resolves a wire string to a role. - * - * @param value the wire value, such as {@code "user"}; may be {@code null} - * @return the matching role, or {@code null} if the value is {@code null} or unrecognized - */ - public static Role fromWireValue(String value) { - if (value == null) { - return null; - } - for (Role role : values()) { - if (role.wireValue.equals(value)) { - return role; - } - } - return null; - } - } - - private final Role role; - private final String content; - - /** - * Constructs a message. - * - * @param role the role of the message; must not be {@code null} - * @param content the message content; must not be {@code null} - * @throws NullPointerException if {@code role} or {@code content} is {@code null} - */ - public LDMessage(Role role, String content) { - this.role = Objects.requireNonNull(role, "role"); - this.content = Objects.requireNonNull(content, "content"); - } - - /** - * Returns the role of this message. - * - * @return the role, never {@code null} - */ - public Role getRole() { - return role; - } - - /** - * Returns the content of this message. - * - * @return the content, never {@code null} - */ - public String getContent() { - return content; - } - - /** - * Returns a copy of this message with the given content, preserving the role. - *

- * Used by the interpolation layer to produce a rendered message without mutating the original. - * - * @param newContent the replacement content; must not be {@code null} - * @return a new {@link LDMessage} - */ - public LDMessage withContent(String newContent) { - return new LDMessage(role, newContent); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof LDMessage)) { - return false; - } - LDMessage other = (LDMessage) o; - return role == other.role && content.equals(other.content); - } - - @Override - public int hashCode() { - return Objects.hash(role, content); - } - - @Override - public String toString() { - return "LDMessage{role=" + role + ", content=" + content + '}'; - } -} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java deleted file mode 100644 index 515440fe..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ModelConfig.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.launchdarkly.sdk.server.ai.datamodel; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Configuration describing the model an AI Config should use. - *

- * Instances are immutable. The {@code parameters} and {@code custom} maps hold arbitrary values - * decoded from the JSON protocol; their values are plain Java types ({@link String}, {@link Double}, - * {@link Boolean}, {@link java.util.List}, {@link java.util.Map}, or {@code null}). Build instances - * with {@link #builder(String)}. - */ -public final class ModelConfig { - private final String name; - private final Map parameters; - private final Map custom; - - private ModelConfig(String name, Map parameters, Map custom) { - this.name = name; - this.parameters = parameters; - this.custom = custom; - } - - /** - * Returns the model name (for example {@code "gpt-4"}). - * - * @return the model name, or {@code null} if none was specified - */ - public String getName() { - return name; - } - - /** - * Returns the model-specific parameters as an unmodifiable map. - * - * @return the parameters; never {@code null} (empty when none were specified) - */ - public Map getParameters() { - return parameters; - } - - /** - * Returns customer-provided custom data as an unmodifiable map. - * - * @return the custom data; never {@code null} (empty when none was specified) - */ - public Map getCustom() { - return custom; - } - - /** - * Retrieves a single model parameter by key. - * - * @param key the parameter name - * @return the value, or {@code null} if absent - */ - public Object getParameter(String key) { - return parameters.get(key); - } - - /** - * Retrieves a single custom-data entry by key. - * - * @param key the custom-data name - * @return the value, or {@code null} if absent - */ - public Object getCustom(String key) { - return custom.get(key); - } - - /** - * Creates a builder for a model with the given name. - * - * @param name the model name - * @return a new {@link Builder} - */ - public static Builder builder(String name) { - return new Builder(name); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ModelConfig)) { - return false; - } - ModelConfig other = (ModelConfig) o; - return Objects.equals(name, other.name) - && parameters.equals(other.parameters) - && custom.equals(other.custom); - } - - @Override - public int hashCode() { - return Objects.hash(name, parameters, custom); - } - - @Override - public String toString() { - return "ModelConfig{name=" + name + ", parameters=" + parameters + ", custom=" + custom + '}'; - } - - /** - * Builder for {@link ModelConfig}. - */ - public static final class Builder { - private final String name; - private Map parameters; - private Map custom; - - private Builder(String name) { - this.name = name; - } - - /** - * Sets the model-specific parameters. The map is copied defensively. - * - * @param parameters the parameters; may be {@code null} - * @return this builder - */ - public Builder parameters(Map parameters) { - this.parameters = parameters == null ? null : new HashMap<>(parameters); - return this; - } - - /** - * Sets customer-provided custom data. The map is copied defensively. - * - * @param custom the custom data; may be {@code null} - * @return this builder - */ - public Builder custom(Map custom) { - this.custom = custom == null ? null : new HashMap<>(custom); - return this; - } - - /** - * Builds the immutable {@link ModelConfig}. - * - * @return a new {@link ModelConfig} - */ - public ModelConfig build() { - Map params = parameters == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new HashMap<>(parameters)); - Map cust = custom == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new HashMap<>(custom)); - return new ModelConfig(name, params, cust); - } - } -} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java deleted file mode 100644 index eb9e232e..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ProviderConfig.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.launchdarkly.sdk.server.ai.datamodel; - -import java.util.Objects; - -/** - * Configuration describing the provider an AI Config should use. - *

- * Instances are immutable. - */ -public final class ProviderConfig { - private final String name; - - /** - * Constructs a provider configuration. - * - * @param name the provider name (for example {@code "openai"}); may be {@code null} - */ - public ProviderConfig(String name) { - this.name = name; - } - - /** - * Returns the provider name. - * - * @return the provider name, or {@code null} if none was specified - */ - public String getName() { - return name; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ProviderConfig)) { - return false; - } - return Objects.equals(name, ((ProviderConfig) o).name); - } - - @Override - public int hashCode() { - return Objects.hashCode(name); - } - - @Override - public String toString() { - return "ProviderConfig{name=" + name + '}'; - } -} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java deleted file mode 100644 index 5bcb8b3e..00000000 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/ToolConfig.java +++ /dev/null @@ -1,189 +0,0 @@ -package com.launchdarkly.sdk.server.ai.datamodel; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -/** - * A single entry from the root-level {@code tools} map of an AI Config flag variation. - *

- * This is distinct from {@code model.parameters.tools[]}, which is the raw array passed through to - * LLM providers. Instances are immutable; build them with {@link #builder(String)}. - */ -public final class ToolConfig { - private final String name; - private final String description; - private final String type; - private final Map parameters; - private final Map customParameters; - - private ToolConfig( - String name, - String description, - String type, - Map parameters, - Map customParameters) { - this.name = name; - this.description = description; - this.type = type; - this.parameters = parameters; - this.customParameters = customParameters; - } - - /** - * Returns the tool name. - * - * @return the tool name, or {@code null} if none was specified - */ - public String getName() { - return name; - } - - /** - * Returns the tool description. - * - * @return the description, or {@code null} if none was specified - */ - public String getDescription() { - return description; - } - - /** - * Returns the tool type. - * - * @return the type, or {@code null} if none was specified - */ - public String getType() { - return type; - } - - /** - * Returns the tool parameters as an unmodifiable map. - * - * @return the parameters; never {@code null} (empty when none were specified) - */ - public Map getParameters() { - return parameters; - } - - /** - * Returns the tool custom parameters as an unmodifiable map. - * - * @return the custom parameters; never {@code null} (empty when none were specified) - */ - public Map getCustomParameters() { - return customParameters; - } - - /** - * Creates a builder for a tool with the given name. - * - * @param name the tool name - * @return a new {@link Builder} - */ - public static Builder builder(String name) { - return new Builder(name); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ToolConfig)) { - return false; - } - ToolConfig other = (ToolConfig) o; - return Objects.equals(name, other.name) - && Objects.equals(description, other.description) - && Objects.equals(type, other.type) - && parameters.equals(other.parameters) - && customParameters.equals(other.customParameters); - } - - @Override - public int hashCode() { - return Objects.hash(name, description, type, parameters, customParameters); - } - - @Override - public String toString() { - return "ToolConfig{name=" + name + ", description=" + description + ", type=" + type - + ", parameters=" + parameters + ", customParameters=" + customParameters + '}'; - } - - /** - * Builder for {@link ToolConfig}. - */ - public static final class Builder { - private final String name; - private String description; - private String type; - private Map parameters; - private Map customParameters; - - private Builder(String name) { - this.name = name; - } - - /** - * Sets the tool description. - * - * @param description the description; may be {@code null} - * @return this builder - */ - public Builder description(String description) { - this.description = description; - return this; - } - - /** - * Sets the tool type. - * - * @param type the type; may be {@code null} - * @return this builder - */ - public Builder type(String type) { - this.type = type; - return this; - } - - /** - * Sets the tool parameters. The map is copied defensively. - * - * @param parameters the parameters; may be {@code null} - * @return this builder - */ - public Builder parameters(Map parameters) { - this.parameters = parameters == null ? null : new HashMap<>(parameters); - return this; - } - - /** - * Sets the tool custom parameters. The map is copied defensively. - * - * @param customParameters the custom parameters; may be {@code null} - * @return this builder - */ - public Builder customParameters(Map customParameters) { - this.customParameters = customParameters == null ? null : new HashMap<>(customParameters); - return this; - } - - /** - * Builds the immutable {@link ToolConfig}. - * - * @return a new {@link ToolConfig} - */ - public ToolConfig build() { - Map params = parameters == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new HashMap<>(parameters)); - Map customParams = customParameters == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new HashMap<>(customParameters)); - return new ToolConfig(name, description, type, params, customParams); - } - } -} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java index d393c006..e8ba2373 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java @@ -1,11 +1,11 @@ package com.launchdarkly.sdk.server.ai.internal; -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.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +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 com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; import java.util.Collections; import java.util.List; @@ -29,12 +29,12 @@ public final class AIConfigFlagValue { private final Boolean enabled; private final String variationKey; private final Integer version; - private final AIConfigMode mode; - private final ModelConfig model; - private final ProviderConfig provider; - private final List messages; + private final Mode mode; + private final Model model; + private final Provider provider; + private final List messages; private final String instructions; - private final Map tools; + private final Map tools; private final JudgeConfiguration judgeConfiguration; private final String evaluationMetricKey; @@ -93,7 +93,7 @@ public Integer getVersion() { * * @return the mode, or {@code null} if absent or unrecognized */ - public AIConfigMode getMode() { + public Mode getMode() { return mode; } @@ -102,7 +102,7 @@ public AIConfigMode getMode() { * * @return the model, or {@code null} if absent */ - public ModelConfig getModel() { + public Model getModel() { return model; } @@ -111,7 +111,7 @@ public ModelConfig getModel() { * * @return the provider, or {@code null} if absent */ - public ProviderConfig getProvider() { + public Provider getProvider() { return provider; } @@ -120,7 +120,7 @@ public ProviderConfig getProvider() { * * @return an unmodifiable list of messages, or {@code null} if absent */ - public List getMessages() { + public List getMessages() { return messages; } @@ -138,7 +138,7 @@ public String getInstructions() { * * @return an unmodifiable map keyed by tool name, or {@code null} if absent */ - public Map getTools() { + public Map getTools() { return tools; } @@ -176,12 +176,12 @@ public static final class Builder { private Boolean enabled; private String variationKey; private Integer version; - private AIConfigMode mode; - private ModelConfig model; - private ProviderConfig provider; - private List messages; + private Mode mode; + private Model model; + private Provider provider; + private List messages; private String instructions; - private Map tools; + private Map tools; private JudgeConfiguration judgeConfiguration; private String evaluationMetricKey; @@ -227,7 +227,7 @@ public Builder version(Integer v) { * @param v the mode * @return this builder */ - public Builder mode(AIConfigMode v) { + public Builder mode(Mode v) { this.mode = v; return this; } @@ -238,7 +238,7 @@ public Builder mode(AIConfigMode v) { * @param v the model * @return this builder */ - public Builder model(ModelConfig v) { + public Builder model(Model v) { this.model = v; return this; } @@ -249,7 +249,7 @@ public Builder model(ModelConfig v) { * @param v the provider * @return this builder */ - public Builder provider(ProviderConfig v) { + public Builder provider(Provider v) { this.provider = v; return this; } @@ -260,7 +260,7 @@ public Builder provider(ProviderConfig v) { * @param v the messages * @return this builder */ - public Builder messages(List v) { + public Builder messages(List v) { this.messages = v; return this; } @@ -282,7 +282,7 @@ public Builder instructions(String v) { * @param v the tools * @return this builder */ - public Builder tools(Map v) { + public Builder tools(Map v) { this.tools = v; return this; } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index d5e07cf6..29be0506 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -2,12 +2,12 @@ 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.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.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +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 com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -66,20 +66,20 @@ private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { if (version.getType() == LDValueType.NUMBER) { builder.version(version.intValue()); } - builder.mode(AIConfigMode.fromWireValue(asStringOrNull(meta.get("mode")))); + builder.mode(Mode.fromWireValue(asStringOrNull(meta.get("mode")))); } /** * Parses a {@code model} object. * * @param model the model value - * @return a {@link ModelConfig}, or {@code null} if the value is not an object + * @return a {@link Model}, or {@code null} if the value is not an object */ - static ModelConfig parseModel(LDValue model) { + static Model parseModel(LDValue model) { if (model == null || model.getType() != LDValueType.OBJECT) { return null; } - return ModelConfig.builder(asStringOrNull(model.get("name"))) + return Model.builder(asStringOrNull(model.get("name"))) .parameters(LDValueConverter.toMap(model.get("parameters"))) .custom(LDValueConverter.toMap(model.get("custom"))) .build(); @@ -89,13 +89,13 @@ static ModelConfig parseModel(LDValue model) { * Parses a {@code provider} object. * * @param provider the provider value - * @return a {@link ProviderConfig}, or {@code null} if the value is not an object + * @return a {@link Provider}, or {@code null} if the value is not an object */ - static ProviderConfig parseProvider(LDValue provider) { + static Provider parseProvider(LDValue provider) { if (provider == null || provider.getType() != LDValueType.OBJECT) { return null; } - return new ProviderConfig(asStringOrNull(provider.get("name"))); + return new Provider(asStringOrNull(provider.get("name"))); } /** @@ -103,23 +103,23 @@ static ProviderConfig parseProvider(LDValue provider) { * are skipped. * * @param messages the messages value - * @return a list of {@link LDMessage}, or {@code null} if the value is not an array + * @return a list of {@link Message}, or {@code null} if the value is not an array */ - static List parseMessages(LDValue messages) { + static List parseMessages(LDValue messages) { if (messages == null || messages.getType() != LDValueType.ARRAY) { return null; } - List result = new ArrayList<>(messages.size()); + List result = new ArrayList<>(messages.size()); for (LDValue entry : messages.values()) { if (entry == null || entry.getType() != LDValueType.OBJECT) { continue; } - LDMessage.Role role = LDMessage.Role.fromWireValue(asStringOrNull(entry.get("role"))); + Message.Role role = Message.Role.fromWireValue(asStringOrNull(entry.get("role"))); LDValue content = entry.get("content"); if (role == null || content.getType() != LDValueType.STRING) { continue; } - result.add(new LDMessage(role, content.stringValue())); + result.add(new Message(role, content.stringValue())); } return result; } @@ -131,12 +131,12 @@ static List parseMessages(LDValue messages) { * @param flagValue the full flag value object * @return a map keyed by tool name, or {@code null} when no tools are present */ - static Map resolveTools(LDValue flagValue) { + static Map resolveTools(LDValue flagValue) { LDValue tools = flagValue.get("tools"); if (tools.getType() == LDValueType.OBJECT) { - Map result = new LinkedHashMap<>(); + Map result = new LinkedHashMap<>(); for (String name : tools.keys()) { - ToolConfig tool = parseTool(name, tools.get(name)); + Tool tool = parseTool(name, tools.get(name)); if (tool != null) { result.put(name, tool); } @@ -148,13 +148,13 @@ static Map resolveTools(LDValue flagValue) { if (rawTools.getType() != LDValueType.ARRAY) { return null; } - Map result = new LinkedHashMap<>(); + Map result = new LinkedHashMap<>(); for (LDValue entry : rawTools.values()) { String name = asStringOrNull(entry.get("name")); if (name == null) { continue; } - ToolConfig tool = parseTool(name, entry); + Tool tool = parseTool(name, entry); if (tool != null) { result.put(name, tool); } @@ -162,7 +162,7 @@ static Map resolveTools(LDValue flagValue) { return result.isEmpty() ? null : result; } - private static ToolConfig parseTool(String fallbackName, LDValue tool) { + private static Tool parseTool(String fallbackName, LDValue tool) { if (tool == null || tool.getType() != LDValueType.OBJECT) { return null; } @@ -170,7 +170,7 @@ private static ToolConfig parseTool(String fallbackName, LDValue tool) { if (name == null) { name = fallbackName; } - return ToolConfig.builder(name) + return Tool.builder(name) .description(asStringOrNull(tool.get("description"))) .type(asStringOrNull(tool.get("type"))) .parameters(LDValueConverter.toMap(tool.get("parameters"))) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java index c4af388d..a38770a7 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java @@ -8,9 +8,9 @@ import static org.hamcrest.Matchers.nullValue; import com.launchdarkly.sdk.LDValue; -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.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; import java.util.List; @@ -37,14 +37,14 @@ public void parsesFullCompletionConfig() { assertThat(parsed.isEnabled(), is(true)); assertThat(parsed.getVariationKey(), is("v1")); assertThat(parsed.getVersion(), is(3)); - assertThat(parsed.getMode(), is(AIConfigMode.COMPLETION)); + assertThat(parsed.getMode(), is(Mode.COMPLETION)); assertThat(parsed.getModel().getName(), is("gpt-4")); assertThat(parsed.getModel().getParameter("temperature"), is((Object) 0.7)); assertThat(parsed.getModel().getParameter("maxTokens"), is((Object) 100L)); assertThat(parsed.getModel().getCustom("team"), is((Object) "core")); assertThat(parsed.getProvider().getName(), is("openai")); assertThat(parsed.getMessages(), hasSize(2)); - assertThat(parsed.getMessages().get(0).getRole(), is(LDMessage.Role.SYSTEM)); + assertThat(parsed.getMessages().get(0).getRole(), is(Message.Role.SYSTEM)); JudgeConfiguration judges = parsed.getJudgeConfiguration(); assertThat(judges.getJudges(), hasSize(1)); assertThat(judges.getJudges().get(0).getKey(), is("j1")); @@ -58,7 +58,7 @@ public void parsesAgentInstructions() { LDValue value = LDValue.parse("{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + "\"instructions\":\"Help {{name}}\"}"); AIConfigFlagValue parsed = AIConfigParser.parse(value); - assertThat(parsed.getMode(), is(AIConfigMode.AGENT)); + assertThat(parsed.getMode(), is(Mode.AGENT)); assertThat(parsed.getInstructions(), is("Help {{name}}")); assertThat(parsed.getMessages(), is(nullValue())); } @@ -88,9 +88,9 @@ public void skipsMalformedMessagesButKeepsValidOnes() { + "{\"role\":\"user\",\"content\":123}," // non-string content + "{\"role\":\"assistant\",\"content\":\"valid\"}" + "]}"); - List messages = AIConfigParser.parse(value).getMessages(); + List messages = AIConfigParser.parse(value).getMessages(); assertThat(messages, hasSize(1)); - assertThat(messages.get(0).getRole(), is(LDMessage.Role.ASSISTANT)); + assertThat(messages.get(0).getRole(), is(Message.Role.ASSISTANT)); assertThat(messages.get(0).getContent(), is("valid")); }