diff --git a/lib/sdk/server-ai/README.md b/lib/sdk/server-ai/README.md index a2f2243d..b2d8d4f2 100644 --- a/lib/sdk/server-ai/README.md +++ b/lib/sdk/server-ai/README.md @@ -18,8 +18,33 @@ This library has a minimum Java version of 8. This module is part of the [`java-core`](https://github.com/launchdarkly/java-core) monorepo and is published to Maven Central as `com.launchdarkly:launchdarkly-java-server-sdk-ai`. -Full usage documentation, including AI Config retrieval, tracking, and manual judge evaluation, will be -added as the SDK is built out (see epic AIC-2629). +Construct an `LDAIClient` from an initialized server-side `LDClient`, then retrieve a typed config: + +```java +LDClient ldClient = new LDClient(sdkKey); +LDAIClient aiClient = new LDAIClientImpl(ldClient); + +Map variables = new HashMap<>(); +variables.put("username", "Sandy"); + +AICompletionConfig config = aiClient.completionConfig( + "my-ai-config-key", + context, + AICompletionConfigDefault.disabled(), // fallback when the flag is absent + variables); + +if (config.isEnabled()) { + // config.getModel(), config.getProvider(), and config.getMessages() (already interpolated) + // are ready to pass to your model provider. +} +``` + +The companion `agentConfig`/`agentConfigs` and `judgeConfig` methods retrieve agent and judge +configs respectively. Within a prompt message or agent instruction, the evaluation context is +available as `{{ldctx}}` (for example `{{ldctx.key}}`). + +Metric tracking and manual judge evaluation will be added as the SDK is built out (see epic +AIC-2629). ## Internal API convention diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java new file mode 100644 index 00000000..5df6b067 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java @@ -0,0 +1,66 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Tool; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A retrieved agent AI Config. This is the result of {@link LDAIClient#agentConfig} (and each entry + * returned by {@link LDAIClient#agentConfigs}). + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ * This class is thread-safe. It holds only the immutable, thread-safe base client, a logger, and a + * single shared {@link Interpolator} (whose compiled-template cache is itself thread-safe); every + * config it returns is immutable. + */ +public final class LDAIClientImpl implements LDAIClient { + private static final String TRACK_SDK_INFO = "$ld:ai:sdk:info"; + private static final String TRACK_USAGE_COMPLETION_CONFIG = "$ld:ai:usage:completion-config"; + private static final String TRACK_USAGE_AGENT_CONFIG = "$ld:ai:usage:agent-config"; + private static final String TRACK_USAGE_AGENT_CONFIGS = "$ld:ai:usage:agent-configs"; + private static final String TRACK_USAGE_JUDGE_CONFIG = "$ld:ai:usage:judge-config"; + + private static final LDContext INIT_TRACK_CONTEXT = LDContext + .builder("ld-internal-tracking") + .kind(ContextKind.of("ld_ai")) + .anonymous(true) + .build(); + + // Tracking is implemented in a later step; until then every config hands out the no-op tracker. + private static final Supplier TRACKER_FACTORY = () -> NoOpAIConfigTracker.INSTANCE; + + private final LDClientInterface client; + private final LDLogger logger; + private final Interpolator interpolator; + + /** + * Creates an AI client wrapping the given base client, using a default logger. + * + * @param client an initialized server-side {@code LDClient}; must not be {@code null} + */ + public LDAIClientImpl(LDClientInterface client) { + this(client, defaultLogger()); + } + + /** + * Creates an AI client wrapping the given base client and logging through the given logger. + * + * @param client an initialized server-side {@code LDClient}; must not be {@code null} + * @param logger the logger to use for warnings; must not be {@code null} + */ + public LDAIClientImpl(LDClientInterface client, LDLogger logger) { + this.client = Objects.requireNonNull(client, "client"); + this.logger = Objects.requireNonNull(logger, "logger"); + this.interpolator = new Interpolator(); + + LDValue info = LDValue.buildObject() + .put("aiSdkName", AISdkInfo.NAME) + .put("aiSdkVersion", AISdkInfo.VERSION) + .put("aiSdkLanguage", AISdkInfo.LANGUAGE) + .build(); + client.trackMetric(TRACK_SDK_INFO, INIT_TRACK_CONTEXT, info, 1); + } + + @Override + public AICompletionConfig completionConfig( + String key, + LDContext context, + AICompletionConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1); + AICompletionConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled(); + return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables); + } + + @Override + public AIAgentConfig agentConfig( + String key, + LDContext context, + AIAgentConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_AGENT_CONFIG, context, LDValue.of(key), 1); + return evaluateAgent(key, context, defaultValue, variables); + } + + @Override + public Map agentConfigs( + List agentConfigs, LDContext context) { + int count = agentConfigs == null ? 0 : agentConfigs.size(); + client.trackMetric(TRACK_USAGE_AGENT_CONFIGS, context, LDValue.of(count), count); + + Map result = new LinkedHashMap<>(); + if (agentConfigs != null) { + for (AIAgentConfigRequest request : agentConfigs) { + if (request == null) { + continue; + } + result.put( + request.getKey(), + evaluateAgent(request.getKey(), context, request.getDefaultValue(), request.getVariables())); + } + } + return result; + } + + @Override + public AIJudgeConfig judgeConfig( + String key, + LDContext context, + AIJudgeConfigDefault defaultValue, + Map variables) { + client.trackMetric(TRACK_USAGE_JUDGE_CONFIG, context, LDValue.of(key), 1); + AIJudgeConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled(); + return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables); + } + + private AIAgentConfig evaluateAgent( + String key, LDContext context, AIAgentConfigDefault defaultValue, Map variables) { + AIAgentConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled(); + return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables); + } + + /** + * Core evaluation: evaluate the flag with a null sentinel default, validate the mode, and build + * the typed config with interpolated prompt content. When the flag is absent or cannot be + * evaluated, the caller's typed default is returned directly (no JSON round-trip). + */ + private AIConfig evaluate( + String key, + LDContext context, + AIConfigDefault defaultValue, + Mode mode, + Map variables) { + LDValue value = client.jsonValueVariation(key, context, LDValue.ofNull()); + + // A valid AI Config variation is always a JSON object (it carries the _ldMeta block). When the + // flag is absent or cannot be evaluated the base SDK hands back our null sentinel; in that case + // we return the caller's typed default directly rather than serializing it and parsing it back. + if (value == null || value.getType() != LDValueType.OBJECT) { + return buildConfigFromDefault(key, mode, defaultValue, context, variables); + } + + AIConfigFlagValue parsed = AIConfigParser.parse(value); + + Mode flagMode = parsed.getMode() != null ? parsed.getMode() : Mode.COMPLETION; + if (flagMode != mode) { + logger.warn( + "AI Config mode mismatch for {}: expected {}, got {}. Returning default config.", + key, mode.getWireValue(), flagMode.getWireValue()); + return buildConfigFromDefault(key, mode, defaultValue, context, variables); + } + + return buildConfig(key, mode, parsed, context, variables); + } + + private AIConfig buildConfig( + String key, + Mode mode, + AIConfigFlagValue parsed, + LDContext context, + Map variables) { + switch (mode) { + case AGENT: + return new AIAgentConfig( + key, + parsed.isEnabled(), + parsed.getModel(), + parsed.getProvider(), + interpolate(parsed.getInstructions(), variables, context), + parsed.getJudgeConfiguration(), + parsed.getTools(), + TRACKER_FACTORY); + case JUDGE: + return new AIJudgeConfig( + key, + parsed.isEnabled(), + parsed.getModel(), + parsed.getProvider(), + interpolateMessages(parsed.getMessages(), variables, context), + parsed.getEvaluationMetricKey(), + TRACKER_FACTORY); + case COMPLETION: + default: + return new AICompletionConfig( + key, + parsed.isEnabled(), + parsed.getModel(), + parsed.getProvider(), + interpolateMessages(parsed.getMessages(), variables, context), + parsed.getJudgeConfiguration(), + parsed.getTools(), + TRACKER_FACTORY); + } + } + + /** + * Builds the typed config straight from the caller-supplied default, used when the flag is absent + * or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag. + */ + private AIConfig buildConfigFromDefault( + String key, + Mode mode, + AIConfigDefault defaultValue, + LDContext context, + Map variables) { + switch (mode) { + case AGENT: { + AIAgentConfigDefault agent = (AIAgentConfigDefault) defaultValue; + return new AIAgentConfig( + key, + agent.isEnabled(), + agent.getModel(), + agent.getProvider(), + interpolate(agent.getInstructions(), variables, context), + agent.getJudgeConfiguration(), + agent.getTools(), + TRACKER_FACTORY); + } + case JUDGE: { + AIJudgeConfigDefault judge = (AIJudgeConfigDefault) defaultValue; + return new AIJudgeConfig( + key, + judge.isEnabled(), + judge.getModel(), + judge.getProvider(), + interpolateMessages(judge.getMessages(), variables, context), + judge.getEvaluationMetricKey(), + TRACKER_FACTORY); + } + case COMPLETION: + default: { + AICompletionConfigDefault completion = (AICompletionConfigDefault) defaultValue; + return new AICompletionConfig( + key, + completion.isEnabled(), + completion.getModel(), + completion.getProvider(), + interpolateMessages(completion.getMessages(), variables, context), + completion.getJudgeConfiguration(), + completion.getTools(), + TRACKER_FACTORY); + } + } + } + + private List interpolateMessages( + List messages, Map variables, LDContext context) { + if (messages == null) { + return null; + } + List result = new ArrayList<>(messages.size()); + for (Message message : messages) { + result.add(message.withContent(interpolator.interpolate(message.getContent(), variables, context))); + } + return result; + } + + private String interpolate(String template, Map variables, LDContext context) { + return interpolator.interpolate(template, variables, context); + } + + private static LDLogger defaultLogger() { + LDLogAdapter adapter; + try { + Class.forName("org.slf4j.LoggerFactory"); + adapter = LDSLF4J.adapter(); + } catch (ClassNotFoundException e) { + adapter = Logs.toConsole(); + } + return LDLogger.withAdapter(adapter, "LaunchDarkly.AI"); + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java new file mode 100644 index 00000000..a298e33b --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java @@ -0,0 +1,16 @@ +package com.launchdarkly.sdk.server.ai; + +/** + * Reports events related to a single AI run of an {@link AIConfig}. + *

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

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

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

+ * This must be kept in step with the {@code version} in {@code gradle.properties} (which + * {@code release-please} updates on release). It is a plain constant rather than a manifest + * lookup so that it resolves correctly in unit tests and when the classes are used outside the + * packaged jar. + */ + // x-release-please-start-version + public static final String VERSION = "0.1.0"; + // x-release-please-end + + private AISdkInfo() { + } +} diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java new file mode 100644 index 00000000..1cbc3c51 --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server.ai.internal; + +import com.launchdarkly.sdk.server.ai.LDAIConfigTracker; + +/** + * The no-op {@link LDAIConfigTracker} used until metric reporting is implemented in a later step of + * the AI SDK. It is immutable and stateless, so a single shared instance is safe to reuse. + *

+ * This class is an internal implementation detail and is not part of the supported API. + */ +public final class NoOpAIConfigTracker implements LDAIConfigTracker { + /** + * The shared instance. + */ + public static final NoOpAIConfigTracker INSTANCE = new NoOpAIConfigTracker(); + + private NoOpAIConfigTracker() { + } +} diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java new file mode 100644 index 00000000..b9bebb99 --- /dev/null +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -0,0 +1,281 @@ +package com.launchdarkly.sdk.server.ai; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Mode; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message; +import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class LDAIClientImplTest { + private LDClientInterface client; + private LogCapture logCapture; + private LDLogger logger; + private LDAIClientImpl ai; + private final LDContext context = LDContext.create("user-key"); + + @Before + public void setUp() { + client = mock(LDClientInterface.class); + logCapture = Logs.capture(); + logger = LDLogger.withAdapter(logCapture, "test"); + ai = new LDAIClientImpl(client, logger); + } + + private List warnings() { + return logCapture.getMessages().stream() + .filter(m -> m.getLevel() == LDLogLevel.WARN) + .map(LogCapture.Message::getText) + .collect(Collectors.toList()); + } + + // ---- SDK info ------------------------------------------------------------- + + @Test + public void constructorEmitsSdkInfoEvent() { + LDValue expected = LDValue.buildObject() + .put("aiSdkName", "launchdarkly-java-server-sdk-ai") + .put("aiSdkVersion", "0.1.0") + .put("aiSdkLanguage", "java") + .build(); + verify(client).trackMetric(eq("$ld:ai:sdk:info"), any(LDContext.class), eq(expected), eq(1.0)); + } + + // ---- Usage events --------------------------------------------------------- + + @Test + public void completionConfigFiresUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.completionConfig("my-key", context, null, null); + verify(client).trackMetric(eq("$ld:ai:usage:completion-config"), eq(context), eq(LDValue.of("my-key")), eq(1.0)); + } + + @Test + public void agentConfigFiresUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.agentConfig("agent-key", context, null, null); + verify(client).trackMetric(eq("$ld:ai:usage:agent-config"), eq(context), eq(LDValue.of("agent-key")), eq(1.0)); + } + + @Test + public void judgeConfigFiresUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.judgeConfig("judge-key", context, null, null); + verify(client).trackMetric(eq("$ld:ai:usage:judge-config"), eq(context), eq(LDValue.of("judge-key")), eq(1.0)); + } + + @Test + public void agentConfigsFiresUsageEventWithCount() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + List requests = Arrays.asList( + AIAgentConfigRequest.builder("a").build(), + AIAgentConfigRequest.builder("b").build()); + ai.agentConfigs(requests, context); + verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(2)), eq(2.0)); + } + + // ---- Typed retrieval + interpolation ------------------------------------- + + @Test + public void completionConfigReturnsTypedConfigFromVariation() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"model\":{\"name\":\"gpt-4\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"Hello {{name}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + Map variables = new HashMap<>(); + variables.put("name", "World"); + AICompletionConfig config = ai.completionConfig("key", context, null, variables); + + assertThat(config, is(notNullValue())); + assertThat(config.getKey(), is("key")); + assertThat(config.isEnabled(), is(true)); + assertThat(config.getMode(), is(Mode.COMPLETION)); + assertThat(config.getModel().getName(), is("gpt-4")); + assertThat(config.getMessages(), hasSize(1)); + assertThat(config.getMessages().get(0).getContent(), is("Hello World")); + assertThat(config.getMessages().get(0).getRole(), is(Message.Role.SYSTEM)); + } + + @Test + public void interpolationExposesContextAsLdctx() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"{{ldctx.key}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfig config = ai.completionConfig("key", LDContext.create("ctx-123"), null, null); + assertThat(config.getMessages().get(0).getContent(), is("ctx-123")); + } + + @Test + public void agentConfigInterpolatesInstructions() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"You research {{topic}}\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + Map variables = new HashMap<>(); + variables.put("topic", "climate"); + AIAgentConfig config = ai.agentConfig("key", context, null, variables); + + assertThat(config.getMode(), is(Mode.AGENT)); + assertThat(config.getInstructions(), is("You research climate")); + } + + @Test + public void judgeConfigResolvesFirstNonBlankEvaluationMetricKey() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"}," + + "\"evaluationMetricKeys\":[\" \",\"\",\"relevance\",\"other\"]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIJudgeConfig config = ai.judgeConfig("key", context, null, null); + assertThat(config.getMode(), is(Mode.JUDGE)); + assertThat(config.getEvaluationMetricKey(), is("relevance")); + } + + // ---- Mode validation ------------------------------------------------------ + + @Test + public void modeMismatchReturnsDefaultConfigAndWarnsOnce() { + String agentJson = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"hi\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(agentJson)); + + // Requesting a completion config against an agent-mode flag returns the caller's default. + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(true) + .model(Model.builder("default-model").build()) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "default {{x}}"))) + .build(); + Map variables = new HashMap<>(); + variables.put("x", "value"); + + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables); + + assertThat(config, is(notNullValue())); + assertThat(config.getMode(), is(Mode.COMPLETION)); + assertThat(config.isEnabled(), is(true)); + assertThat(config.getModel().getName(), is("default-model")); + assertThat(config.getMessages().get(0).getContent(), is("default value")); + assertThat(warnings(), hasSize(1)); + assertThat(warnings().get(0), containsString("mode mismatch")); + } + + @Test + public void matchingModeDoesNotWarn() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + ai.completionConfig("key", context, null, null); + assertThat(warnings(), is(empty())); + } + + // ---- Default semantics ---------------------------------------------------- + + @Test + public void absentFlagReturnsConfiguredDefault() { + // Simulate an absent flag: the base SDK returns the null sentinel default we pass in, which the + // client treats as "flag not found" and resolves to the caller's typed default. + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(true) + .model(Model.builder("default-model").build()) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "default {{x}}"))) + .build(); + + Map variables = new HashMap<>(); + variables.put("x", "value"); + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables); + + assertThat(config.isEnabled(), is(true)); + assertThat(config.getModel().getName(), is("default-model")); + assertThat(config.getMessages().get(0).getContent(), is("default value")); + } + + @Test + public void nullDefaultYieldsDisabledConfigWhenAbsent() { + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + AICompletionConfig config = ai.completionConfig("key", context, null, null); + assertThat(config.isEnabled(), is(false)); + } + + @Test + public void doesNotMergeMissingFieldsFromDefault() { + // The flag is present and enabled but omits messages; the default supplies messages. + // Per the JS-aligned semantics, the result reflects the variation as-is (no per-field merge), + // so messages remain absent. + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},\"model\":{\"name\":\"flag-model\"}}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(false) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "should not appear"))) + .build(); + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables(/* none */)); + + assertThat(config.getModel().getName(), is("flag-model")); + assertThat(config.getMessages(), is(nullValue())); + } + + // ---- agentConfigs --------------------------------------------------------- + + @Test + public void agentConfigsReturnsMapKeyedByRequestPreservingOrder() { + when(client.jsonValueVariation(anyString(), any(), any())).thenAnswer(inv -> { + String key = inv.getArgument(0); + return LDValue.parse("{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"I am " + key + "\"}"); + }); + + List requests = Arrays.asList( + AIAgentConfigRequest.builder("research").build(), + AIAgentConfigRequest.builder("writing").build()); + Map agents = ai.agentConfigs(requests, context); + + assertThat(new ArrayList<>(agents.keySet()), contains("research", "writing")); + assertThat(agents.get("research").getInstructions(), is("I am research")); + assertThat(agents.get("writing").getInstructions(), is("I am writing")); + } + + @Test + public void agentConfigsHandlesEmptyList() { + Map agents = ai.agentConfigs(new ArrayList<>(), context); + assertThat(agents.entrySet(), is(empty())); + verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(0)), eq(0.0)); + } + + private static Map variables() { + return new HashMap<>(); + } +}