Skip to content

Commit 29f31fc

Browse files
authored
feat: Add template config methods to AI SDK (#184)
## Summary Adds `completionConfigTemplate`, `agentConfigTemplate`, and `judgeConfigTemplate` to `LDAIClient`. These methods return the same config types as their non-template counterparts but skip Mustache interpolation, preserving `{{variable}}` and `{{ldctx.key}}` placeholders verbatim. Useful for displaying prompt previews, storing templates for later rendering, or auditing prompt content without variable substitution. ### Template methods on `LDAIClient` ```java AICompletionConfig completionConfigTemplate(String key, LDContext context, AICompletionConfigDefault defaultValue); AIAgentConfig agentConfigTemplate(String key, LDContext context, AIAgentConfigDefault defaultValue); AIJudgeConfig judgeConfigTemplate(String key, LDContext context, AIJudgeConfigDefault defaultValue); ``` Each template method fires a `-template` suffixed usage event (`$ld:ai:usage:completion-config-template`, `$ld:ai:usage:agent-config-template`, `$ld:ai:usage:judge-config-template`) and does **not** fire the standard usage event. The `variables` parameter is omitted since interpolation is skipped. ### `LDAIClientImpl` changes The private `evaluate`, `buildConfig`, and `buildConfigFromDefault` methods accept a new `boolean interpolate` parameter. When `false`, `interpolateMessages()` / `interpolate()` calls are skipped and messages/instructions are passed through as-is from the parsed flag value or caller-supplied default. All existing call sites pass `true` — behavior is unchanged. ```java // Standard path public AICompletionConfig completionConfig(String key, LDContext context, ...) { client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1); return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables, true); } // Template path public AICompletionConfig completionConfigTemplate(String key, LDContext context, ...) { client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE, context, LDValue.of(key), 1); return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, null, false); } ``` ### Migration **None required.** `LDAIClient` gains three new members — this is additive only. Existing consumers that call the SDK through the concrete `LDAIClientImpl` class are unaffected. Consumers that implement `LDAIClient` in test doubles will need to add stub implementations for the three new methods. ## Test plan - [ ] `./gradlew :lib:sdk:server-ai:test` passes - [ ] `completionConfigTemplateFiresTemplateUsageEvent` — verifies the correct `-template` tracking event is emitted - [ ] `completionConfigTemplatePreservesPlaceholders` — `{{name}}` survives in message content - [ ] `completionConfigTemplateDoesNotInterpolateLdctx` — `{{ldctx.key}}` is **not** substituted with the context key - [ ] `completionConfigTemplateNullDefaultYieldsDisabled` — absent flag with null default returns disabled config - [ ] `completionConfigTemplateHasTracker` — `createTracker()` returns non-null - [ ] `agentConfigTemplate*` — same five scenarios for agent configs (instructions rather than messages) - [ ] `judgeConfigTemplate*` — same five scenarios for judge configs <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Additive API and a guarded code path; existing config retrieval still interpolates and is covered by existing plus new tests. > > **Overview** > Adds **`completionConfigTemplate`**, **`agentConfigTemplate`**, and **`judgeConfigTemplate`** on `LDAIClient` so callers can evaluate flags and get the same typed configs while leaving Mustache placeholders (including `{{ldctx.*}}`) unchanged—intended for previews, auditing, and storing templates for later rendering. > > `LDAIClientImpl` threads a new **`interpolate`** flag through `evaluate` / `buildConfig` / `buildConfigFromDefault`; existing `*Config` paths still pass **`true`**, while template methods pass **`false`**, omit variables, and emit **`-template`** usage metrics instead of the standard config usage events. Unit tests cover tracking, placeholder preservation, disabled defaults, and trackers. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 82dcf60. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 74c697d commit 29f31fc

3 files changed

Lines changed: 264 additions & 19 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
@@ -83,6 +83,52 @@ AIJudgeConfig judgeConfig(
8383
AIJudgeConfigDefault defaultValue,
8484
Map<String, Object> variables);
8585

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

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

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ public final class LDAIClientImpl implements LDAIClient {
5050
private static final String TRACK_USAGE_AGENT_CONFIG = "$ld:ai:usage:agent-config";
5151
private static final String TRACK_USAGE_AGENT_CONFIGS = "$ld:ai:usage:agent-configs";
5252
private static final String TRACK_USAGE_JUDGE_CONFIG = "$ld:ai:usage:judge-config";
53+
private static final String TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE = "$ld:ai:usage:completion-config-template";
54+
private static final String TRACK_USAGE_AGENT_CONFIG_TEMPLATE = "$ld:ai:usage:agent-config-template";
55+
private static final String TRACK_USAGE_JUDGE_CONFIG_TEMPLATE = "$ld:ai:usage:judge-config-template";
5356
private static final String TRACK_USAGE_AGENT_GRAPH = "$ld:ai:usage:agent-graph";
5457

5558
private static final LDContext INIT_TRACK_CONTEXT = LDContext
@@ -100,7 +103,7 @@ public AICompletionConfig completionConfig(
100103
client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG, context, LDValue.of(key), 1);
101104
AICompletionConfigDefault effectiveDefault =
102105
defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled();
103-
return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables);
106+
return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables, true);
104107
}
105108

106109
@Override
@@ -148,7 +151,40 @@ public AIJudgeConfig judgeConfig(
148151
client.trackMetric(TRACK_USAGE_JUDGE_CONFIG, context, LDValue.of(key), 1);
149152
AIJudgeConfigDefault effectiveDefault =
150153
defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled();
151-
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables);
154+
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables, true);
155+
}
156+
157+
@Override
158+
public AICompletionConfig completionConfigTemplate(
159+
String key,
160+
LDContext context,
161+
AICompletionConfigDefault defaultValue) {
162+
client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE, context, LDValue.of(key), 1);
163+
AICompletionConfigDefault effectiveDefault =
164+
defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled();
165+
return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, null, false);
166+
}
167+
168+
@Override
169+
public AIAgentConfig agentConfigTemplate(
170+
String key,
171+
LDContext context,
172+
AIAgentConfigDefault defaultValue) {
173+
client.trackMetric(TRACK_USAGE_AGENT_CONFIG_TEMPLATE, context, LDValue.of(key), 1);
174+
AIAgentConfigDefault effectiveDefault =
175+
defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled();
176+
return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, null, false);
177+
}
178+
179+
@Override
180+
public AIJudgeConfig judgeConfigTemplate(
181+
String key,
182+
LDContext context,
183+
AIJudgeConfigDefault defaultValue) {
184+
client.trackMetric(TRACK_USAGE_JUDGE_CONFIG_TEMPLATE, context, LDValue.of(key), 1);
185+
AIJudgeConfigDefault effectiveDefault =
186+
defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled();
187+
return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, null, false);
152188
}
153189

154190
private AIAgentConfig evaluateAgent(
@@ -161,7 +197,7 @@ private AIAgentConfig evaluateAgent(
161197
Map<String, Object> variables, String graphKey) {
162198
AIAgentConfigDefault effectiveDefault =
163199
defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled();
164-
return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables, graphKey);
200+
return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables, true, graphKey);
165201
}
166202

167203
/**
@@ -174,8 +210,9 @@ private AIConfig evaluate(
174210
LDContext context,
175211
AIConfigDefault defaultValue,
176212
Mode mode,
177-
Map<String, Object> variables) {
178-
return evaluate(key, context, defaultValue, mode, variables, null);
213+
Map<String, Object> variables,
214+
boolean interpolate) {
215+
return evaluate(key, context, defaultValue, mode, variables, interpolate, null);
179216
}
180217

181218
private AIConfig evaluate(
@@ -184,14 +221,15 @@ private AIConfig evaluate(
184221
AIConfigDefault defaultValue,
185222
Mode mode,
186223
Map<String, Object> variables,
224+
boolean interpolate,
187225
String graphKey) {
188226
LDValue value = client.jsonValueVariation(key, context, LDValue.ofNull());
189227

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

197235
AIConfigFlagValue parsed = AIConfigParser.parse(value);
@@ -201,19 +239,20 @@ private AIConfig evaluate(
201239
logger.warn(
202240
"AI Config mode mismatch for {}: expected {}, got {}. Returning default config.",
203241
key, mode.getWireValue(), flagMode.getWireValue());
204-
return buildConfigFromDefault(key, mode, defaultValue, context, variables, graphKey);
242+
return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate, graphKey);
205243
}
206244

207-
return buildConfig(key, mode, parsed, context, variables, graphKey);
245+
return buildConfig(key, mode, parsed, context, variables, interpolate, graphKey);
208246
}
209247

210248
private AIConfig buildConfig(
211249
String key,
212250
Mode mode,
213251
AIConfigFlagValue parsed,
214252
LDContext context,
215-
Map<String, Object> variables) {
216-
return buildConfig(key, mode, parsed, context, variables, null);
253+
Map<String, Object> variables,
254+
boolean interpolate) {
255+
return buildConfig(key, mode, parsed, context, variables, interpolate, null);
217256
}
218257

219258
private AIConfig buildConfig(
@@ -222,6 +261,7 @@ private AIConfig buildConfig(
222261
AIConfigFlagValue parsed,
223262
LDContext context,
224263
Map<String, Object> variables,
264+
boolean interpolate,
225265
String graphKey) {
226266
Supplier<LDAIConfigTracker> factory = trackerFactory(
227267
key, parsed.getVariationKey(), parsed.getVersion(),
@@ -233,7 +273,8 @@ private AIConfig buildConfig(
233273
parsed.isEnabled(),
234274
parsed.getModel(),
235275
parsed.getProvider(),
236-
interpolate(parsed.getInstructions(), variables, context),
276+
interpolate ? interpolate(parsed.getInstructions(), variables, context)
277+
: parsed.getInstructions(),
237278
parsed.getJudgeConfiguration(),
238279
parsed.getTools(),
239280
factory,
@@ -244,7 +285,8 @@ private AIConfig buildConfig(
244285
parsed.isEnabled(),
245286
parsed.getModel(),
246287
parsed.getProvider(),
247-
interpolateMessages(parsed.getMessages(), variables, context),
288+
interpolate ? interpolateMessages(parsed.getMessages(), variables, context)
289+
: parsed.getMessages(),
248290
parsed.getEvaluationMetricKey(),
249291
factory);
250292
case COMPLETION:
@@ -254,7 +296,8 @@ private AIConfig buildConfig(
254296
parsed.isEnabled(),
255297
parsed.getModel(),
256298
parsed.getProvider(),
257-
interpolateMessages(parsed.getMessages(), variables, context),
299+
interpolate ? interpolateMessages(parsed.getMessages(), variables, context)
300+
: parsed.getMessages(),
258301
parsed.getJudgeConfiguration(),
259302
parsed.getTools(),
260303
factory,
@@ -264,15 +307,17 @@ private AIConfig buildConfig(
264307

265308
/**
266309
* Builds the typed config straight from the caller-supplied default, used when the flag is absent
267-
* or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag.
310+
* or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag,
311+
* unless {@code interpolate} is {@code false} (template mode).
268312
*/
269313
private AIConfig buildConfigFromDefault(
270314
String key,
271315
Mode mode,
272316
AIConfigDefault defaultValue,
273317
LDContext context,
274-
Map<String, Object> variables) {
275-
return buildConfigFromDefault(key, mode, defaultValue, context, variables, null);
318+
Map<String, Object> variables,
319+
boolean interpolate) {
320+
return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate, null);
276321
}
277322

278323
private AIConfig buildConfigFromDefault(
@@ -281,6 +326,7 @@ private AIConfig buildConfigFromDefault(
281326
AIConfigDefault defaultValue,
282327
LDContext context,
283328
Map<String, Object> variables,
329+
boolean interpolate,
284330
String graphKey) {
285331
// Default configs still get real trackers — the configKey was requested even if no flag was found.
286332
// variationKey is null because no flag evaluation occurred.
@@ -293,7 +339,8 @@ private AIConfig buildConfigFromDefault(
293339
agent.isEnabled(),
294340
agent.getModel(),
295341
agent.getProvider(),
296-
interpolate(agent.getInstructions(), variables, context),
342+
interpolate ? interpolate(agent.getInstructions(), variables, context)
343+
: agent.getInstructions(),
297344
agent.getJudgeConfiguration(),
298345
agent.getTools(),
299346
factory,
@@ -306,7 +353,8 @@ private AIConfig buildConfigFromDefault(
306353
judge.isEnabled(),
307354
judge.getModel(),
308355
judge.getProvider(),
309-
interpolateMessages(judge.getMessages(), variables, context),
356+
interpolate ? interpolateMessages(judge.getMessages(), variables, context)
357+
: judge.getMessages(),
310358
judge.getEvaluationMetricKey(),
311359
factory);
312360
}
@@ -318,7 +366,8 @@ private AIConfig buildConfigFromDefault(
318366
completion.isEnabled(),
319367
completion.getModel(),
320368
completion.getProvider(),
321-
interpolateMessages(completion.getMessages(), variables, context),
369+
interpolate ? interpolateMessages(completion.getMessages(), variables, context)
370+
: completion.getMessages(),
322371
completion.getJudgeConfiguration(),
323372
completion.getTools(),
324373
factory,

0 commit comments

Comments
 (0)