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