From 31e6032b52fb3e0398556d402ce97a8296c87ea4 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 9 Jun 2026 11:31:47 -0400 Subject: [PATCH 1/4] feat: add AICONF config types & LDAIClient retrieval methods (AIC-2663) Implement the public AI Config types and the LDAIClient retrieval methods that evaluate a flag, validate its mode, interpolate prompt templates, and return a typed config. Config types follow the .NET-style two-hierarchy design (AIConfig / AIConfigDefault bases): AICompletionConfig(+messages), AIAgentConfig (+instructions), AIJudgeConfig(+evaluationMetricKey) results plus parallel *Default builder types (a generic base builder keeps the defaults DRY), and AIAgentConfigRequest for batch agent retrieval. LDAIClientImpl mirrors the JS reference the spec points to: - serialize the caller default to a flag value (with the requested mode) and pass it through jsonValueVariation, so an absent flag yields the default and the eval event records the correct default; - validate mode: a mismatch logs a single warning and returns a disabled config of the requested type (never a config that would NPE the caller); - interpolate messages/instructions via the vendored-Mustache Interpolator, exposing the context as {{ldctx}}; - fire the spec'd usage events ($ld:ai:usage:completion-config, :agent-config, :agent-configs, :judge-config) and emit $ld:ai:sdk:info once at construction, guarded so an uninitialized client can't throw from the constructor. Design decisions (documented in the PR): - Synchronous API (no CompletableFuture): matches the core Java server SDK and avoids Android-API/threading concerns; variation is in-memory post-init so agentConfigs fan-out parallelism buys ~nothing. - No per-field merge of missing fields from the default (matches JS). - Tracking is deferred to Step 4 (AIC-2664): LDAIConfigTracker is a placeholder interface with an internal no-op; configs expose createTracker() so Step 4 fills in behavior without reshaping the public types. Adds LDValueConverter.fromJavaObject (inverse conversion for default serialization). LDAIClientImplTest covers usage events, typed retrieval, interpolation/ldctx, mode-mismatch, default semantics, and agentConfigs. Co-authored-by: Cursor --- lib/sdk/server-ai/README.md | 29 +- .../sdk/server/ai/AIAgentConfig.java | 66 +++ .../sdk/server/ai/AIAgentConfigDefault.java | 132 ++++++ .../sdk/server/ai/AIAgentConfigRequest.java | 107 +++++ .../sdk/server/ai/AICompletionConfig.java | 68 +++ .../server/ai/AICompletionConfigDefault.java | 136 ++++++ .../launchdarkly/sdk/server/ai/AIConfig.java | 105 +++++ .../sdk/server/ai/AIConfigDefault.java | 115 ++++++ .../sdk/server/ai/AIJudgeConfig.java | 54 +++ .../sdk/server/ai/AIJudgeConfigDefault.java | 108 +++++ .../sdk/server/ai/LDAIClient.java | 84 ++++ .../sdk/server/ai/LDAIClientImpl.java | 386 ++++++++++++++++++ .../sdk/server/ai/LDAIConfigTracker.java | 16 + .../sdk/server/ai/internal/AISdkInfo.java | 33 ++ .../server/ai/internal/LDValueConverter.java | 60 +++ .../ai/internal/NoOpAIConfigTracker.java | 19 + .../sdk/server/ai/LDAIClientImplTest.java | 280 +++++++++++++ .../ai/internal/LDValueConverterTest.java | 42 ++ 18 files changed, 1838 insertions(+), 2 deletions(-) create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfigRequest.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AICompletionConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfig.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIJudgeConfigDefault.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClient.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIConfigTracker.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java create mode 100644 lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/NoOpAIConfigTracker.java create mode 100644 lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java diff --git a/lib/sdk/server-ai/README.md b/lib/sdk/server-ai/README.md index a2f2243d..b2d8d4f2 100644 --- a/lib/sdk/server-ai/README.md +++ b/lib/sdk/server-ai/README.md @@ -18,8 +18,33 @@ This library has a minimum Java version of 8. This module is part of the [`java-core`](https://github.com/launchdarkly/java-core) monorepo and is published to Maven Central as `com.launchdarkly:launchdarkly-java-server-sdk-ai`. -Full usage documentation, including AI Config retrieval, tracking, and manual judge evaluation, will be -added as the SDK is built out (see epic AIC-2629). +Construct an `LDAIClient` from an initialized server-side `LDClient`, then retrieve a typed config: + +```java +LDClient ldClient = new LDClient(sdkKey); +LDAIClient aiClient = new LDAIClientImpl(ldClient); + +Map variables = new HashMap<>(); +variables.put("username", "Sandy"); + +AICompletionConfig config = aiClient.completionConfig( + "my-ai-config-key", + context, + AICompletionConfigDefault.disabled(), // fallback when the flag is absent + variables); + +if (config.isEnabled()) { + // config.getModel(), config.getProvider(), and config.getMessages() (already interpolated) + // are ready to pass to your model provider. +} +``` + +The companion `agentConfig`/`agentConfigs` and `judgeConfig` methods retrieve agent and judge +configs respectively. Within a prompt message or agent instruction, the evaluation context is +available as `{{ldctx}}` (for example `{{ldctx.key}}`). + +Metric tracking and manual judge evaluation will be added as the SDK is built out (see epic +AIC-2629). ## Internal API convention diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java new file mode 100644 index 00000000..77d65bee --- /dev/null +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIAgentConfig.java @@ -0,0 +1,66 @@ +package com.launchdarkly.sdk.server.ai; + +import com.launchdarkly.sdk.server.ai.datamodel.AIConfigMode; +import com.launchdarkly.sdk.server.ai.datamodel.JudgeConfiguration; +import com.launchdarkly.sdk.server.ai.datamodel.ModelConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ProviderConfig; +import com.launchdarkly.sdk.server.ai.datamodel.ToolConfig; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A retrieved agent AI Config. This is the result of {@link LDAIClient#agentConfig} (and each entry + * returned by {@link LDAIClient#agentConfigs}). + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Implementations are thread-safe. */ diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java index 3269621b..cb6fe4ad 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java @@ -78,18 +78,12 @@ public LDAIClientImpl(LDClientInterface client, LDLogger logger) { this.logger = Objects.requireNonNull(logger, "logger"); this.interpolator = new Interpolator(); - // Report SDK info once. Guard it: if the base client is not yet fully initialized, a track call - // must never propagate an exception out of this constructor. - try { - LDValue info = LDValue.buildObject() - .put("aiSdkName", AISdkInfo.NAME) - .put("aiSdkVersion", AISdkInfo.VERSION) - .put("aiSdkLanguage", AISdkInfo.LANGUAGE) - .build(); - client.trackMetric(TRACK_SDK_INFO, INIT_TRACK_CONTEXT, info, 1); - } catch (Exception e) { - this.logger.warn("Unable to record AI SDK info event: {}", e.toString()); - } + LDValue info = LDValue.buildObject() + .put("aiSdkName", AISdkInfo.NAME) + .put("aiSdkVersion", AISdkInfo.VERSION) + .put("aiSdkLanguage", AISdkInfo.LANGUAGE) + .build(); + client.trackMetric(TRACK_SDK_INFO, INIT_TRACK_CONTEXT, info, 1); } @Override @@ -178,9 +172,9 @@ private AIConfig evaluate( Mode flagMode = parsed.getMode() != null ? parsed.getMode() : Mode.COMPLETION; if (flagMode != mode) { logger.warn( - "AI Config mode mismatch for {}: expected {}, got {}. Returning disabled config.", + "AI Config mode mismatch for {}: expected {}, got {}. Returning default config.", key, mode.getWireValue(), flagMode.getWireValue()); - return disabledConfig(key, mode); + return buildConfigFromDefault(key, mode, defaultValue, context, variables); } return buildConfig(key, mode, parsed, context, variables); @@ -276,18 +270,6 @@ private AIConfig buildConfigFromDefault( } } - private AIConfig disabledConfig(String key, Mode mode) { - switch (mode) { - case AGENT: - return new AIAgentConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); - case JUDGE: - return new AIJudgeConfig(key, false, null, null, null, null, TRACKER_FACTORY); - case COMPLETION: - default: - return new AICompletionConfig(key, false, null, null, null, null, null, TRACKER_FACTORY); - } - } - private List interpolateMessages( List messages, Map variables, LDContext context) { if (messages == null) { diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java index 42cdee10..6e76357f 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AISdkInfo.java @@ -25,8 +25,9 @@ public final class AISdkInfo { * lookup so that it resolves correctly in unit tests and when the classes are used outside the * packaged jar. */ - // x-release-please-version + // x-release-please-start-version public static final String VERSION = "0.1.0"; + // x-release-please-end private AISdkInfo() { } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java index 11253f45..b9bebb99 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java @@ -71,15 +71,6 @@ public void constructorEmitsSdkInfoEvent() { verify(client).trackMetric(eq("$ld:ai:sdk:info"), any(LDContext.class), eq(expected), eq(1.0)); } - @Test - public void constructorDoesNotThrowWhenSdkInfoTrackingFails() { - LDClientInterface throwingClient = mock(LDClientInterface.class); - org.mockito.Mockito.doThrow(new RuntimeException("not initialized")) - .when(throwingClient).trackMetric(eq("$ld:ai:sdk:info"), any(), any(), eq(1.0)); - // Must not propagate out of the constructor. - new LDAIClientImpl(throwingClient, logger); - } - // ---- Usage events --------------------------------------------------------- @Test @@ -174,18 +165,27 @@ public void judgeConfigResolvesFirstNonBlankEvaluationMetricKey() { // ---- Mode validation ------------------------------------------------------ @Test - public void modeMismatchReturnsDisabledConfigAndWarnsOnce() { + public void modeMismatchReturnsDefaultConfigAndWarnsOnce() { String agentJson = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + "\"instructions\":\"hi\"}"; when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(agentJson)); - // Requesting a completion config against an agent-mode flag. - AICompletionConfig config = ai.completionConfig("key", context, null, null); + // Requesting a completion config against an agent-mode flag returns the caller's default. + AICompletionConfigDefault dflt = AICompletionConfigDefault.builder() + .enabled(true) + .model(Model.builder("default-model").build()) + .messages(Arrays.asList(new Message(Message.Role.SYSTEM, "default {{x}}"))) + .build(); + Map variables = new HashMap<>(); + variables.put("x", "value"); + + AICompletionConfig config = ai.completionConfig("key", context, dflt, variables); assertThat(config, is(notNullValue())); - assertThat(config.isEnabled(), is(false)); - assertThat(config.getMessages(), is(nullValue())); assertThat(config.getMode(), is(Mode.COMPLETION)); + assertThat(config.isEnabled(), is(true)); + assertThat(config.getModel().getName(), is("default-model")); + assertThat(config.getMessages().get(0).getContent(), is("default value")); assertThat(warnings(), hasSize(1)); assertThat(warnings().get(0), containsString("mode mismatch")); }