Skip to content

Commit b5c9cdf

Browse files
committed
feat: add completionConfigTemplate, agentConfigTemplate, and judgeConfigTemplate methods
1 parent 65d0b5b commit b5c9cdf

3 files changed

Lines changed: 258 additions & 16 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,52 @@ AIJudgeConfig judgeConfig(
8282
AIJudgeConfigDefault defaultValue,
8383
Map<String, Object> variables);
8484

85+
/**
86+
* Retrieves a completion (chat/prompt) AI Config with Mustache placeholders left intact
87+
* (no interpolation). Useful for displaying prompt previews or storing templates for later
88+
* rendering.
89+
*
90+
* @param key the AI Config key
91+
* @param context the context to evaluate the configuration in
92+
* @param defaultValue the default returned when the flag is absent or cannot be evaluated; when
93+
* {@code null}, a disabled default is used
94+
* @return the completion config with raw (non-interpolated) message content, never {@code null}
95+
*/
96+
AICompletionConfig completionConfigTemplate(
97+
String key,
98+
LDContext context,
99+
AICompletionConfigDefault defaultValue);
100+
101+
/**
102+
* Retrieves an agent AI Config with Mustache placeholders left intact (no interpolation). Useful
103+
* for auditing instruction templates or building UI previews.
104+
*
105+
* @param key the AI Config key
106+
* @param context the context to evaluate the configuration in
107+
* @param defaultValue the default returned when the flag is absent or cannot be evaluated; when
108+
* {@code null}, a disabled default is used
109+
* @return the agent config with raw (non-interpolated) instructions, never {@code null}
110+
*/
111+
AIAgentConfig agentConfigTemplate(
112+
String key,
113+
LDContext context,
114+
AIAgentConfigDefault defaultValue);
115+
116+
/**
117+
* Retrieves a judge AI Config with Mustache placeholders left intact (no interpolation). Useful
118+
* for auditing judge prompt templates.
119+
*
120+
* @param key the AI Config key
121+
* @param context the context to evaluate the configuration in
122+
* @param defaultValue the default returned when the flag is absent or cannot be evaluated; when
123+
* {@code null}, a disabled default is used
124+
* @return the judge config with raw (non-interpolated) message content, never {@code null}
125+
*/
126+
AIJudgeConfig judgeConfigTemplate(
127+
String key,
128+
LDContext context,
129+
AIJudgeConfigDefault defaultValue);
130+
85131
/**
86132
* Reconstructs a tracker from a resumption token, preserving the original run's identity.
87133
* <p>

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

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public final class LDAIClientImpl implements LDAIClient {
4545
private static final String TRACK_USAGE_AGENT_CONFIG = "$ld:ai:usage:agent-config";
4646
private static final String TRACK_USAGE_AGENT_CONFIGS = "$ld:ai:usage:agent-configs";
4747
private static final String TRACK_USAGE_JUDGE_CONFIG = "$ld:ai:usage:judge-config";
48+
private static final String TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE = "$ld:ai:usage:completion-config-template";
49+
private static final String TRACK_USAGE_AGENT_CONFIG_TEMPLATE = "$ld:ai:usage:agent-config-template";
50+
private static final String TRACK_USAGE_JUDGE_CONFIG_TEMPLATE = "$ld:ai:usage:judge-config-template";
4851

4952
private static final LDContext INIT_TRACK_CONTEXT = LDContext
5053
.builder("ld-internal-tracking")
@@ -94,7 +97,7 @@ public AICompletionConfig completionConfig(
9497
client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1);
9598
AICompletionConfigDefault effectiveDefault =
9699
defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled();
97-
return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables);
100+
return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables, true);
98101
}
99102

100103
@Override
@@ -137,14 +140,47 @@ public AIJudgeConfig judgeConfig(
137140
client.trackMetric(TRACK_USAGE_JUDGE_CONFIG, context, LDValue.of(key), 1);
138141
AIJudgeConfigDefault effectiveDefault =
139142
defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled();
140-
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables);
143+
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables, true);
144+
}
145+
146+
@Override
147+
public AICompletionConfig completionConfigTemplate(
148+
String key,
149+
LDContext context,
150+
AICompletionConfigDefault defaultValue) {
151+
client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE, context, LDValue.of(key), 1);
152+
AICompletionConfigDefault effectiveDefault =
153+
defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled();
154+
return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, null, false);
155+
}
156+
157+
@Override
158+
public AIAgentConfig agentConfigTemplate(
159+
String key,
160+
LDContext context,
161+
AIAgentConfigDefault defaultValue) {
162+
client.trackMetric(TRACK_USAGE_AGENT_CONFIG_TEMPLATE, context, LDValue.of(key), 1);
163+
AIAgentConfigDefault effectiveDefault =
164+
defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled();
165+
return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, null, false);
166+
}
167+
168+
@Override
169+
public AIJudgeConfig judgeConfigTemplate(
170+
String key,
171+
LDContext context,
172+
AIJudgeConfigDefault defaultValue) {
173+
client.trackMetric(TRACK_USAGE_JUDGE_CONFIG_TEMPLATE, context, LDValue.of(key), 1);
174+
AIJudgeConfigDefault effectiveDefault =
175+
defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled();
176+
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, null, false);
141177
}
142178

143179
private AIAgentConfig evaluateAgent(
144180
String key, LDContext context, AIAgentConfigDefault defaultValue, Map<String, Object> variables) {
145181
AIAgentConfigDefault effectiveDefault =
146182
defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled();
147-
return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables);
183+
return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables, true);
148184
}
149185

150186
/**
@@ -157,14 +193,15 @@ private AIConfig evaluate(
157193
LDContext context,
158194
AIConfigDefault defaultValue,
159195
Mode mode,
160-
Map<String, Object> variables) {
196+
Map<String, Object> variables,
197+
boolean interpolate) {
161198
LDValue value = client.jsonValueVariation(key, context, LDValue.ofNull());
162199

163200
// A valid AI Config variation is always a JSON object (it carries the _ldMeta block). When the
164201
// flag is absent or cannot be evaluated the base SDK hands back our null sentinel; in that case
165202
// we return the caller's typed default directly rather than serializing it and parsing it back.
166203
if (value == null || value.getType() != LDValueType.OBJECT) {
167-
return buildConfigFromDefault(key, mode, defaultValue, context, variables);
204+
return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate);
168205
}
169206

170207
AIConfigFlagValue parsed = AIConfigParser.parse(value);
@@ -174,18 +211,19 @@ private AIConfig evaluate(
174211
logger.warn(
175212
"AI Config mode mismatch for {}: expected {}, got {}. Returning default config.",
176213
key, mode.getWireValue(), flagMode.getWireValue());
177-
return buildConfigFromDefault(key, mode, defaultValue, context, variables);
214+
return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate);
178215
}
179216

180-
return buildConfig(key, mode, parsed, context, variables);
217+
return buildConfig(key, mode, parsed, context, variables, interpolate);
181218
}
182219

183220
private AIConfig buildConfig(
184221
String key,
185222
Mode mode,
186223
AIConfigFlagValue parsed,
187224
LDContext context,
188-
Map<String, Object> variables) {
225+
Map<String, Object> variables,
226+
boolean interpolate) {
189227
Supplier<LDAIConfigTracker> factory = trackerFactory(
190228
key, parsed.getVariationKey(), parsed.getVersion(),
191229
parsed.getModel(), parsed.getProvider(), context);
@@ -196,7 +234,8 @@ private AIConfig buildConfig(
196234
parsed.isEnabled(),
197235
parsed.getModel(),
198236
parsed.getProvider(),
199-
interpolate(parsed.getInstructions(), variables, context),
237+
interpolate ? interpolate(parsed.getInstructions(), variables, context)
238+
: parsed.getInstructions(),
200239
parsed.getJudgeConfiguration(),
201240
parsed.getTools(),
202241
factory);
@@ -206,7 +245,8 @@ private AIConfig buildConfig(
206245
parsed.isEnabled(),
207246
parsed.getModel(),
208247
parsed.getProvider(),
209-
interpolateMessages(parsed.getMessages(), variables, context),
248+
interpolate ? interpolateMessages(parsed.getMessages(), variables, context)
249+
: parsed.getMessages(),
210250
parsed.getEvaluationMetricKey(),
211251
factory);
212252
case COMPLETION:
@@ -216,7 +256,8 @@ private AIConfig buildConfig(
216256
parsed.isEnabled(),
217257
parsed.getModel(),
218258
parsed.getProvider(),
219-
interpolateMessages(parsed.getMessages(), variables, context),
259+
interpolate ? interpolateMessages(parsed.getMessages(), variables, context)
260+
: parsed.getMessages(),
220261
parsed.getJudgeConfiguration(),
221262
parsed.getTools(),
222263
factory);
@@ -225,14 +266,16 @@ private AIConfig buildConfig(
225266

226267
/**
227268
* Builds the typed config straight from the caller-supplied default, used when the flag is absent
228-
* or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag.
269+
* or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag,
270+
* unless {@code interpolate} is {@code false} (template mode).
229271
*/
230272
private AIConfig buildConfigFromDefault(
231273
String key,
232274
Mode mode,
233275
AIConfigDefault defaultValue,
234276
LDContext context,
235-
Map<String, Object> variables) {
277+
Map<String, Object> variables,
278+
boolean interpolate) {
236279
// Default configs still get real trackers — the configKey was requested even if no flag was found.
237280
// variationKey is null because no flag evaluation occurred.
238281
Supplier<LDAIConfigTracker> factory = trackerFactory(key, null, null, null, null, context);
@@ -244,7 +287,8 @@ private AIConfig buildConfigFromDefault(
244287
agent.isEnabled(),
245288
agent.getModel(),
246289
agent.getProvider(),
247-
interpolate(agent.getInstructions(), variables, context),
290+
interpolate ? interpolate(agent.getInstructions(), variables, context)
291+
: agent.getInstructions(),
248292
agent.getJudgeConfiguration(),
249293
agent.getTools(),
250294
factory);
@@ -256,7 +300,8 @@ private AIConfig buildConfigFromDefault(
256300
judge.isEnabled(),
257301
judge.getModel(),
258302
judge.getProvider(),
259-
interpolateMessages(judge.getMessages(), variables, context),
303+
interpolate ? interpolateMessages(judge.getMessages(), variables, context)
304+
: judge.getMessages(),
260305
judge.getEvaluationMetricKey(),
261306
factory);
262307
}
@@ -268,7 +313,8 @@ private AIConfig buildConfigFromDefault(
268313
completion.isEnabled(),
269314
completion.getModel(),
270315
completion.getProvider(),
271-
interpolateMessages(completion.getMessages(), variables, context),
316+
interpolate ? interpolateMessages(completion.getMessages(), variables, context)
317+
: completion.getMessages(),
272318
completion.getJudgeConfiguration(),
273319
completion.getTools(),
274320
factory);

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

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,156 @@ public void agentConfigsUsageCountExcludesNullEntries() {
289289
verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(2)), eq(2.0));
290290
}
291291

292+
// ---- Template config methods ----------------------------------------------
293+
294+
// Tracking events
295+
296+
@Test
297+
public void completionConfigTemplateFiresTemplateUsageEvent() {
298+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull());
299+
ai.completionConfigTemplate("my-key", context, null);
300+
verify(client).trackMetric(
301+
eq("$ld:ai:usage:completion-config-template"), eq(context), eq(LDValue.of("my-key")), eq(1.0));
302+
}
303+
304+
@Test
305+
public void agentConfigTemplateFiresTemplateUsageEvent() {
306+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull());
307+
ai.agentConfigTemplate("agent-key", context, null);
308+
verify(client).trackMetric(
309+
eq("$ld:ai:usage:agent-config-template"), eq(context), eq(LDValue.of("agent-key")), eq(1.0));
310+
}
311+
312+
@Test
313+
public void judgeConfigTemplateFiresTemplateUsageEvent() {
314+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull());
315+
ai.judgeConfigTemplate("judge-key", context, null);
316+
verify(client).trackMetric(
317+
eq("$ld:ai:usage:judge-config-template"), eq(context), eq(LDValue.of("judge-key")), eq(1.0));
318+
}
319+
320+
// Placeholder preservation
321+
322+
@Test
323+
public void completionConfigTemplatePreservesPlaceholders() {
324+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},"
325+
+ "\"messages\":[{\"role\":\"system\",\"content\":\"Hello {{name}}\"}]}";
326+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
327+
328+
AICompletionConfig config = ai.completionConfigTemplate("key", context, null);
329+
assertThat(config.getMessages().get(0).getContent(), is("Hello {{name}}"));
330+
}
331+
332+
@Test
333+
public void agentConfigTemplatePreservesPlaceholders() {
334+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"},"
335+
+ "\"instructions\":\"You research {{topic}}\"}";
336+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
337+
338+
AIAgentConfig config = ai.agentConfigTemplate("key", context, null);
339+
assertThat(config.getInstructions(), is("You research {{topic}}"));
340+
}
341+
342+
@Test
343+
public void judgeConfigTemplatePreservesPlaceholders() {
344+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"},"
345+
+ "\"messages\":[{\"role\":\"user\",\"content\":\"Rate {{response}}\"}]}";
346+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
347+
348+
AIJudgeConfig config = ai.judgeConfigTemplate("key", context, null);
349+
assertThat(config.getMessages().get(0).getContent(), is("Rate {{response}}"));
350+
}
351+
352+
// ldctx non-interpolation
353+
354+
@Test
355+
public void completionConfigTemplateDoesNotInterpolateLdctx() {
356+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},"
357+
+ "\"messages\":[{\"role\":\"user\",\"content\":\"{{ldctx.key}}\"}]}";
358+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
359+
360+
AICompletionConfig config = ai.completionConfigTemplate("key", LDContext.create("ctx-123"), null);
361+
assertThat(config.getMessages().get(0).getContent(), is("{{ldctx.key}}"));
362+
}
363+
364+
@Test
365+
public void agentConfigTemplateDoesNotInterpolateLdctx() {
366+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"},"
367+
+ "\"instructions\":\"Hello {{ldctx.key}}\"}";
368+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
369+
370+
AIAgentConfig config = ai.agentConfigTemplate("key", LDContext.create("ctx-123"), null);
371+
assertThat(config.getInstructions(), is("Hello {{ldctx.key}}"));
372+
}
373+
374+
@Test
375+
public void judgeConfigTemplateDoesNotInterpolateLdctx() {
376+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"},"
377+
+ "\"messages\":[{\"role\":\"user\",\"content\":\"{{ldctx.key}}\"}]}";
378+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
379+
380+
AIJudgeConfig config = ai.judgeConfigTemplate("key", LDContext.create("ctx-123"), null);
381+
assertThat(config.getMessages().get(0).getContent(), is("{{ldctx.key}}"));
382+
}
383+
384+
// Null default yields disabled config
385+
386+
@Test
387+
public void completionConfigTemplateNullDefaultYieldsDisabled() {
388+
when(client.jsonValueVariation(anyString(), any(), any()))
389+
.thenAnswer(inv -> inv.getArgument(2, LDValue.class));
390+
AICompletionConfig config = ai.completionConfigTemplate("key", context, null);
391+
assertThat(config.isEnabled(), is(false));
392+
}
393+
394+
@Test
395+
public void agentConfigTemplateNullDefaultYieldsDisabled() {
396+
when(client.jsonValueVariation(anyString(), any(), any()))
397+
.thenAnswer(inv -> inv.getArgument(2, LDValue.class));
398+
AIAgentConfig config = ai.agentConfigTemplate("key", context, null);
399+
assertThat(config.isEnabled(), is(false));
400+
}
401+
402+
@Test
403+
public void judgeConfigTemplateNullDefaultYieldsDisabled() {
404+
when(client.jsonValueVariation(anyString(), any(), any()))
405+
.thenAnswer(inv -> inv.getArgument(2, LDValue.class));
406+
AIJudgeConfig config = ai.judgeConfigTemplate("key", context, null);
407+
assertThat(config.isEnabled(), is(false));
408+
}
409+
410+
// Tracker non-null
411+
412+
@Test
413+
public void completionConfigTemplateHasTracker() {
414+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},"
415+
+ "\"messages\":[{\"role\":\"system\",\"content\":\"Hello {{name}}\"}]}";
416+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
417+
418+
AICompletionConfig config = ai.completionConfigTemplate("key", context, null);
419+
assertThat(config.createTracker(), is(notNullValue()));
420+
}
421+
422+
@Test
423+
public void agentConfigTemplateHasTracker() {
424+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"},"
425+
+ "\"instructions\":\"You research {{topic}}\"}";
426+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
427+
428+
AIAgentConfig config = ai.agentConfigTemplate("key", context, null);
429+
assertThat(config.createTracker(), is(notNullValue()));
430+
}
431+
432+
@Test
433+
public void judgeConfigTemplateHasTracker() {
434+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"},"
435+
+ "\"messages\":[{\"role\":\"user\",\"content\":\"Rate {{response}}\"}]}";
436+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
437+
438+
AIJudgeConfig config = ai.judgeConfigTemplate("key", context, null);
439+
assertThat(config.createTracker(), is(notNullValue()));
440+
}
441+
292442
private static Map<String, Object> variables() {
293443
return new HashMap<>();
294444
}

0 commit comments

Comments
 (0)