Skip to content

Commit a46dcbd

Browse files
ctawiahcursoragent
andcommitted
feat: add AI Config data model, defensive parsing & Mustache interpolation (AIC-2662)
Implements Step 2 of the Java AI SDK: the AICONF data model plus the JSON-protocol parsing and interpolation layers. No client methods, tracker, or evaluation are included (those are later steps). Public data model (com.launchdarkly.sdk.server.ai.datamodel): - LDMessage (role enum + content), ModelConfig, ProviderConfig, ToolConfig, JudgeConfiguration (+ nested Judge), AIConfigMode. - Immutable, builder-based, documented public types. Internal parsing/interpolation (com.launchdarkly.sdk.server.ai.internal): - LDValueConverter: depth-capped LDValue -> plain Java tree. Integral numbers within +/-2^53 decode to Long, otherwise Double (precision beyond 2^53 is documented). - AIConfigParser + AIConfigFlagValue: defensive LDValue -> typed parse. Malformed/wrong-typed fields never throw; tools fall back to model.parameters.tools[]; evaluationMetricKey resolves to the first non-blank of evaluationMetricKey / evaluationMetricKeys[]. - Interpolator: jmustache engine matching the JS/Python escaping policy (no HTML escaping, missing/null -> ""), with ldctx merged last so it overrides any caller-supplied ldctx, and a thread-safe compiled-template cache. Build: - Re-add the (now audited) com.samskivert:jmustache:1.16 dependency as implementation scope; flip javadoc failOnError back on now that public types exist. Tests: 32 unit tests covering defensive parsing fallbacks, number/tool/ metric-key resolution, tri-state enabled, and interpolation parity (escaping, missing-variable, ldctx-wins). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1800281 commit a46dcbd

14 files changed

Lines changed: 1848 additions & 9 deletions

File tree

lib/sdk/server-ai/build.gradle

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@ ext {
4444
ext.versions = [
4545
// The *lowest* version of the base SDK we are compatible with. LDClientInterface
4646
// appears in this library's public signature, so it is exposed as an `api` dependency.
47-
"sdk": "7.14.0"
48-
// NOTE: a Mustache templating dependency (for AI Config message/instruction interpolation)
49-
// will be added in a later step once it has been fully audited (license / maintenance /
50-
// transitive deps). See AIC-2662.
47+
"sdk": "7.14.0",
48+
// jmustache: Mustache engine for AI Config message/instruction interpolation.
49+
// Audit (AIC-2662): com.samskivert:jmustache 1.16 is BSD 2-Clause licensed, actively
50+
// maintained, and ships as a single self-contained jar with no transitive runtime
51+
// dependencies (verified via the published POM). Chosen over mustache.java / Handlebars.java
52+
// for its zero-dependency footprint; HTML escaping is disabled to match the JS/Python SDKs.
53+
"jmustache": "1.16"
5154
]
5255

5356
ext.libraries = [:]
@@ -56,6 +59,9 @@ dependencies {
5659
// Exposed on the public API surface (LDClientInterface), therefore `api` not `implementation`.
5760
api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}"
5861

62+
// Mustache templating, kept as `implementation` so it is not leaked onto consumers' classpath.
63+
implementation "com.samskivert:jmustache:${versions.jmustache}"
64+
5965
testImplementation "org.hamcrest:hamcrest-all:1.3"
6066
testImplementation "junit:junit:4.13.2"
6167
testImplementation "org.mockito:mockito-core:3.12.4"
@@ -74,11 +80,6 @@ java {
7480
javadoc {
7581
// exclude internal implementation classes from the published API documentation
7682
exclude internalPackageGlob
77-
// The foundation module (AIC-2661) intentionally ships no public types yet, only
78-
// package-info.java. The javadoc tool reports "No public or protected classes found to
79-
// document" in that state, so we tolerate it here. TODO(AIC-2662): set failOnError = true
80-
// once the data-model public types land.
81-
failOnError = false
8283
options {
8384
// suppress noisy "no comment" warnings; checkstyle enforces Javadoc on the public surface
8485
addStringOption('Xdoclint:all,-missing', '-quiet')
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.launchdarkly.sdk.server.ai.datamodel;
2+
3+
/**
4+
* The mode of an AI Config, as carried by the {@code _ldMeta.mode} field of a flag variation.
5+
* <p>
6+
* The mode determines which kind of configuration a variation represents and which retrieval
7+
* method on the client it is valid for.
8+
*/
9+
public enum AIConfigMode {
10+
/**
11+
* A completion (chat/prompt) configuration. This is the default when no mode is present.
12+
*/
13+
COMPLETION("completion"),
14+
15+
/**
16+
* An agent configuration, which carries {@code instructions} instead of {@code messages}.
17+
*/
18+
AGENT("agent"),
19+
20+
/**
21+
* A judge configuration, used to evaluate the output of another configuration.
22+
*/
23+
JUDGE("judge");
24+
25+
private final String wireValue;
26+
27+
AIConfigMode(String wireValue) {
28+
this.wireValue = wireValue;
29+
}
30+
31+
/**
32+
* Returns the string used to represent this mode in the JSON protocol.
33+
*
34+
* @return the wire representation (for example {@code "completion"})
35+
*/
36+
public String getWireValue() {
37+
return wireValue;
38+
}
39+
40+
/**
41+
* Resolves a wire string to a mode.
42+
*
43+
* @param value the wire value, such as {@code "agent"}; may be {@code null}
44+
* @return the matching mode, or {@code null} if the value is {@code null} or unrecognized
45+
*/
46+
public static AIConfigMode fromWireValue(String value) {
47+
if (value == null) {
48+
return null;
49+
}
50+
for (AIConfigMode mode : values()) {
51+
if (mode.wireValue.equals(value)) {
52+
return mode;
53+
}
54+
}
55+
return null;
56+
}
57+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.launchdarkly.sdk.server.ai.datamodel;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.List;
6+
import java.util.Objects;
7+
8+
/**
9+
* Configuration referencing the judges that may evaluate an AI Config.
10+
* <p>
11+
* This is parsed from the {@code judgeConfiguration} field of a flag variation and is visible on
12+
* completion and agent configs. In v1.0 judges are invoked manually; the SDK does not auto-attach
13+
* them. Instances are immutable.
14+
*/
15+
public final class JudgeConfiguration {
16+
/**
17+
* Configuration for a single judge attachment: which judge AI Config to use and how frequently
18+
* to sample it.
19+
* <p>
20+
* Instances are immutable.
21+
*/
22+
public static final class Judge {
23+
private final String key;
24+
private final double samplingRate;
25+
26+
/**
27+
* Constructs a judge attachment.
28+
*
29+
* @param key the key of the judge AI Config; must not be {@code null}
30+
* @param samplingRate the sampling rate, nominally in the range {@code 0.0}–{@code 1.0}
31+
* @throws NullPointerException if {@code key} is {@code null}
32+
*/
33+
public Judge(String key, double samplingRate) {
34+
this.key = Objects.requireNonNull(key, "key");
35+
this.samplingRate = samplingRate;
36+
}
37+
38+
/**
39+
* Returns the key of the judge AI Config.
40+
*
41+
* @return the judge key, never {@code null}
42+
*/
43+
public String getKey() {
44+
return key;
45+
}
46+
47+
/**
48+
* Returns the configured sampling rate.
49+
*
50+
* @return the sampling rate
51+
*/
52+
public double getSamplingRate() {
53+
return samplingRate;
54+
}
55+
56+
@Override
57+
public boolean equals(Object o) {
58+
if (this == o) {
59+
return true;
60+
}
61+
if (!(o instanceof Judge)) {
62+
return false;
63+
}
64+
Judge other = (Judge) o;
65+
return Double.compare(samplingRate, other.samplingRate) == 0 && key.equals(other.key);
66+
}
67+
68+
@Override
69+
public int hashCode() {
70+
return Objects.hash(key, samplingRate);
71+
}
72+
73+
@Override
74+
public String toString() {
75+
return "Judge{key=" + key + ", samplingRate=" + samplingRate + '}';
76+
}
77+
}
78+
79+
private final List<Judge> judges;
80+
81+
/**
82+
* Constructs a judge configuration.
83+
*
84+
* @param judges the judge attachments; may be {@code null}, treated as empty
85+
*/
86+
public JudgeConfiguration(List<Judge> judges) {
87+
this.judges = judges == null
88+
? Collections.<Judge>emptyList()
89+
: Collections.unmodifiableList(new ArrayList<>(judges));
90+
}
91+
92+
/**
93+
* Returns the configured judge attachments as an unmodifiable list.
94+
*
95+
* @return the judges; never {@code null} (empty when none were specified)
96+
*/
97+
public List<Judge> getJudges() {
98+
return judges;
99+
}
100+
101+
@Override
102+
public boolean equals(Object o) {
103+
if (this == o) {
104+
return true;
105+
}
106+
if (!(o instanceof JudgeConfiguration)) {
107+
return false;
108+
}
109+
return judges.equals(((JudgeConfiguration) o).judges);
110+
}
111+
112+
@Override
113+
public int hashCode() {
114+
return judges.hashCode();
115+
}
116+
117+
@Override
118+
public String toString() {
119+
return "JudgeConfiguration{judges=" + judges + '}';
120+
}
121+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.launchdarkly.sdk.server.ai.datamodel;
2+
3+
import java.util.Objects;
4+
5+
/**
6+
* A single prompt message in an AI Config, consisting of a {@link Role} and string content.
7+
* <p>
8+
* Instances are immutable.
9+
*/
10+
public final class LDMessage {
11+
/**
12+
* The role of a {@link LDMessage}.
13+
*/
14+
public enum Role {
15+
/**
16+
* A system message, typically used to set behavior or context.
17+
*/
18+
SYSTEM("system"),
19+
20+
/**
21+
* A message authored by the end user.
22+
*/
23+
USER("user"),
24+
25+
/**
26+
* A message authored by the assistant (model).
27+
*/
28+
ASSISTANT("assistant");
29+
30+
private final String wireValue;
31+
32+
Role(String wireValue) {
33+
this.wireValue = wireValue;
34+
}
35+
36+
/**
37+
* Returns the string used to represent this role in the JSON protocol.
38+
*
39+
* @return the wire representation (for example {@code "system"})
40+
*/
41+
public String getWireValue() {
42+
return wireValue;
43+
}
44+
45+
/**
46+
* Resolves a wire string to a role.
47+
*
48+
* @param value the wire value, such as {@code "user"}; may be {@code null}
49+
* @return the matching role, or {@code null} if the value is {@code null} or unrecognized
50+
*/
51+
public static Role fromWireValue(String value) {
52+
if (value == null) {
53+
return null;
54+
}
55+
for (Role role : values()) {
56+
if (role.wireValue.equals(value)) {
57+
return role;
58+
}
59+
}
60+
return null;
61+
}
62+
}
63+
64+
private final Role role;
65+
private final String content;
66+
67+
/**
68+
* Constructs a message.
69+
*
70+
* @param role the role of the message; must not be {@code null}
71+
* @param content the message content; must not be {@code null}
72+
* @throws NullPointerException if {@code role} or {@code content} is {@code null}
73+
*/
74+
public LDMessage(Role role, String content) {
75+
this.role = Objects.requireNonNull(role, "role");
76+
this.content = Objects.requireNonNull(content, "content");
77+
}
78+
79+
/**
80+
* Returns the role of this message.
81+
*
82+
* @return the role, never {@code null}
83+
*/
84+
public Role getRole() {
85+
return role;
86+
}
87+
88+
/**
89+
* Returns the content of this message.
90+
*
91+
* @return the content, never {@code null}
92+
*/
93+
public String getContent() {
94+
return content;
95+
}
96+
97+
/**
98+
* Returns a copy of this message with the given content, preserving the role.
99+
* <p>
100+
* Used by the interpolation layer to produce a rendered message without mutating the original.
101+
*
102+
* @param newContent the replacement content; must not be {@code null}
103+
* @return a new {@link LDMessage}
104+
*/
105+
public LDMessage withContent(String newContent) {
106+
return new LDMessage(role, newContent);
107+
}
108+
109+
@Override
110+
public boolean equals(Object o) {
111+
if (this == o) {
112+
return true;
113+
}
114+
if (!(o instanceof LDMessage)) {
115+
return false;
116+
}
117+
LDMessage other = (LDMessage) o;
118+
return role == other.role && content.equals(other.content);
119+
}
120+
121+
@Override
122+
public int hashCode() {
123+
return Objects.hash(role, content);
124+
}
125+
126+
@Override
127+
public String toString() {
128+
return "LDMessage{role=" + role + ", content=" + content + '}';
129+
}
130+
}

0 commit comments

Comments
 (0)