Skip to content

Commit c051f2b

Browse files
authored
refactor: use common LDValueConverter and LDContextEncoder in AI SDK (#188)
## Summary Wires configured judges into completion and agent configs so **online evaluation actually runs**. Previously `completionConfig`/`agentConfig` always attached `Evaluator.noop()` regardless of a config's `judgeConfiguration`, so judges were dormant (intentionally descoped in v1.0 — see #180). This completes that wiring. Applications supply a `Runner` per judge via a new `AIRunnerProvider`; the SDK builds a real `Evaluator` from the `judgeConfiguration`. Behavior is unchanged when no provider is configured — everything falls back to `Evaluator.noop()`. ### New type ```java @FunctionalInterface public interface AIRunnerProvider { // Returns a Runner for the given judge AI Config, or null to skip this judge. Runner create(AIJudgeConfig judgeConfig); } ``` The application implements this to wrap any model provider. The client calls it once per judge key when building an `Evaluator` for a completion or agent config that carries a `judgeConfiguration`. ### New constructor ```java // Existing one- and two-arg constructors delegate with a null provider (online eval disabled). public LDAIClientImpl(LDClientInterface client, LDLogger logger, AIRunnerProvider runnerProvider); ``` ### `LDAIClientImpl` changes The hard-coded `Evaluator.noop()` in the completion and agent paths (both the resolved-variation and default paths) is replaced with `buildEvaluator(judgeConfiguration, context, variables)`. Judge configs themselves still wire `Evaluator.noop()` internally — judges do not evaluate themselves. ```java private Evaluator buildEvaluator(JudgeConfiguration judgeConfig, LDContext context, Map<String, Object> variables) { if (runnerProvider == null || judgeConfig == null || judgeConfig.getJudges().isEmpty()) { return Evaluator.noop(); } // For each judge key: fetch its AI Config (Mode.JUDGE), get a Runner from the // provider, build a Judge. Skip (and log) disabled judges, null runners, or any // construction failure so one bad judge never blocks the parent config. // ... return judges.isEmpty() ? Evaluator.noop() : new Evaluator(judges, judgeConfig, logger); } ``` Per-judge sampling rates continue to flow from the `JudgeConfiguration` through `Evaluator.evaluate` — the `JudgeConfiguration` remains the source of truth. Judge configs are fetched through the internal evaluation path, so building an evaluator does not emit judge usage-metric events. ### Fallback / isolation behavior - Returns `Evaluator.noop()` when no provider is configured, the `judgeConfiguration` is absent/empty, or every judge fails to construct. - A disabled judge config, a `null` runner from the provider, or an exception during construction skips **only that judge** (logged); the parent config is still built with the surviving judges. ### Migration **None required.** Additive only — a new interface plus a new constructor overload. Existing callers behave identically (noop evaluator). Online evaluation is opt-in by passing an `AIRunnerProvider`. ## Test plan - [ ] `./gradlew :lib:sdk:server-ai:test` passes - [ ] `completionConfigWithJudgeConfigAndProviderBuildsRealEvaluator` — real (non-noop) evaluator built - [ ] `agentConfigWithJudgeConfigAndProviderBuildsRealEvaluator` — same for agent configs - [ ] `completionConfigWithNoJudgesYieldsNoopEvaluator` — no `judgeConfiguration` → noop - [ ] `completionConfigWithNullRunnerProviderYieldsNoopEvaluator` — two-arg constructor → noop even with a `judgeConfiguration` - [ ] `disabledJudgeConfigIsFilteredAndEvaluatorIsNoop` — disabled judge skipped and logged - [ ] `nullRunnerFromProviderSkipsThatJudge` — provider returns null → judge skipped, logged - [ ] `throwingRunnerProviderSkipsThatJudgeButKeepsOthers` — one bad judge skipped, others survive ## Additional context - Completes the online-evaluation wiring deferred from v1.0 in #180. - When a provider is configured, each judge in a config's `judgeConfiguration` is fetched and constructed eagerly at config-retrieval time (one flag evaluation per judge key), even if the evaluator is never invoked. - Known follow-up (out of scope): unlike js-core, Java does not yet reserve `message_history` / `response_to_evaluate` variables or strip legacy judge template messages before evaluation. Not a regression. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Internal refactor behind existing parsers/interpolation; risk is mainly behavioral parity if shared encoder/converter semantics differ slightly from the removed local code. > > **Overview** > **Replaces duplicated server-ai internals with `launchdarkly-java-sdk-common` (2.5.0).** > > `server-ai` now depends on **`launchdarkly-java-sdk-common`** and drops the local **`LDValueConverter`** implementation (and its unit tests). **`AIConfigParser`** still maps model/tool JSON via **`LDValueConverter.toMap`**, but that type now comes from **`com.launchdarkly.sdk`**. > > **`Interpolator`** no longer builds the **`ldctx`** Mustache variable with private **`contextToMap`** / **`singleContextToMap`** helpers; it uses **`LDContextEncoder.encode(context)`** instead, while keeping the same rule that caller-supplied **`ldctx`** is overridden. > > No public API changes; this is internal parsing/interpolation alignment with other Java SDKs. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3ca7b7f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 26d01e8 commit c051f2b

5 files changed

Lines changed: 4 additions & 237 deletions

File tree

lib/sdk/server-ai/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ ext.libraries = [:]
5252
dependencies {
5353
// Exposed on the public API surface (LDClientInterface), therefore `api` not `implementation`.
5454
api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}"
55+
implementation "com.launchdarkly:launchdarkly-java-sdk-common:2.5.0"
5556

5657
testImplementation "org.hamcrest:hamcrest-all:1.3"
5758
testImplementation "junit:junit:4.13.2"

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.launchdarkly.sdk.server.ai.internal;
22

33
import com.launchdarkly.sdk.LDValue;
4+
import com.launchdarkly.sdk.LDValueConverter;
45
import com.launchdarkly.sdk.LDValueType;
56
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.JudgeConfiguration;
67
import com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Message;

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.launchdarkly.sdk.server.ai.internal;
22

33
import com.launchdarkly.sdk.LDContext;
4+
import com.launchdarkly.sdk.LDContextEncoder;
45
import com.launchdarkly.sdk.server.ai.internal.mustache.Mustache;
56
import com.launchdarkly.sdk.server.ai.internal.mustache.Template;
67

@@ -61,7 +62,7 @@ public String interpolate(String template, Map<String, Object> variables, LDCont
6162
merged.putAll(variables);
6263
}
6364
// ldctx is added last so it always wins over any caller-supplied "ldctx" entry.
64-
merged.put("ldctx", contextToMap(context));
65+
merged.put("ldctx", LDContextEncoder.encode(context));
6566
return render(template, merged);
6667
}
6768

@@ -83,51 +84,4 @@ private String render(String template, Map<String, Object> variables) {
8384
Template compiled = templateCache.computeIfAbsent(template, compiler::compile);
8485
return compiled.execute(variables);
8586
}
86-
87-
/**
88-
* Encodes the evaluation context directly into the nested map structure exposed to templates as
89-
* {@code ldctx}, without round-tripping through JSON serialization. A single-kind context becomes
90-
* a map of its attributes; a multi-kind context becomes
91-
* {@code {"kind":"multi", "key":<fully-qualified key>, <kind>: {...}}} with one nested map per
92-
* individual context.
93-
*/
94-
private static Map<String, Object> contextToMap(LDContext context) {
95-
if (context == null || !context.isValid()) {
96-
return new HashMap<>();
97-
}
98-
if (context.isMultiple()) {
99-
Map<String, Object> map = new HashMap<>();
100-
map.put("kind", "multi");
101-
map.put("key", context.getFullyQualifiedKey());
102-
int count = context.getIndividualContextCount();
103-
for (int i = 0; i < count; i++) {
104-
LDContext individual = context.getIndividualContext(i);
105-
if (individual != null) {
106-
// Mirror LaunchDarkly's standard context JSON: the per-kind objects nested under a
107-
// multi-kind context omit "kind" because it is already implied by the property key.
108-
map.put(individual.getKind().toString(), singleContextToMap(individual, false));
109-
}
110-
}
111-
return map;
112-
}
113-
return singleContextToMap(context, true);
114-
}
115-
116-
private static Map<String, Object> singleContextToMap(LDContext context, boolean includeKind) {
117-
Map<String, Object> map = new HashMap<>();
118-
if (includeKind) {
119-
map.put("kind", context.getKind().toString());
120-
}
121-
map.put("key", context.getKey());
122-
if (context.getName() != null) {
123-
map.put("name", context.getName());
124-
}
125-
map.put("anonymous", context.isAnonymous());
126-
// Custom attribute values can be arbitrary JSON; convert each LDValue to a plain Java value
127-
// (depth-capped) so nested objects/arrays remain addressable from templates.
128-
for (String attribute : context.getCustomAttributeNames()) {
129-
map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute)));
130-
}
131-
return map;
132-
}
13387
}

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverter.java

Lines changed: 0 additions & 116 deletions
This file was deleted.

lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDValueConverterTest.java

Lines changed: 0 additions & 73 deletions
This file was deleted.

0 commit comments

Comments
 (0)