diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle index e819f8d4..1a3acfec 100644 --- a/lib/sdk/server-ai/build.gradle +++ b/lib/sdk/server-ai/build.gradle @@ -45,9 +45,11 @@ 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. + // 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 = [:] @@ -74,11 +76,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/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= 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/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..e8ba2373 --- /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.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; +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 Mode mode; + private final Model model; + private final Provider 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 Mode getMode() { + return mode; + } + + /** + * Returns the model configuration. + * + * @return the model, or {@code null} if absent + */ + public Model getModel() { + return model; + } + + /** + * Returns the provider configuration. + * + * @return the provider, or {@code null} if absent + */ + public Provider 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 Mode mode; + private Model model; + private Provider 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(Mode v) { + this.mode = v; + return this; + } + + /** + * Sets the model configuration. + * + * @param v the model + * @return this builder + */ + public Builder model(Model v) { + this.model = v; + return this; + } + + /** + * Sets the provider configuration. + * + * @param v the provider + * @return this builder + */ + public Builder provider(Provider 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..29be0506 --- /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.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; +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(Mode.fromWireValue(asStringOrNull(meta.get("mode")))); + } + + /** + * Parses a {@code model} object. + * + * @param model the model value + * @return a {@link Model}, or {@code null} if the value is not an object + */ + static Model parseModel(LDValue model) { + if (model == null || model.getType() != LDValueType.OBJECT) { + return null; + } + return Model.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 Provider}, or {@code null} if the value is not an object + */ + static Provider parseProvider(LDValue provider) { + if (provider == null || provider.getType() != LDValueType.OBJECT) { + return null; + } + return new Provider(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 Message}, 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; + } + 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 Message(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()) { + Tool 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; + } + Tool tool = parseTool(name, entry); + if (tool != null) { + result.put(name, tool); + } + } + return result.isEmpty() ? null : result; + } + + private static Tool 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 Tool.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/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..a38770a7 --- /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.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; + +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(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(Message.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(Mode.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(Message.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/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)); + } +}