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 d0734ba0..0b50f6b8 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 @@ -83,6 +83,52 @@ AIJudgeConfig judgeConfig( AIJudgeConfigDefault defaultValue, Map variables); + /** + * Retrieves a completion (chat/prompt) AI Config with Mustache placeholders left intact + * (no interpolation). Useful for displaying prompt previews or storing templates for later + * rendering. + * + * @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 + * @return the completion config with raw (non-interpolated) message content, never {@code null} + */ + AICompletionConfig completionConfigTemplate( + String key, + LDContext context, + AICompletionConfigDefault defaultValue); + + /** + * Retrieves an agent AI Config with Mustache placeholders left intact (no interpolation). Useful + * for auditing instruction templates or building UI previews. + * + * @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 + * @return the agent config with raw (non-interpolated) instructions, never {@code null} + */ + AIAgentConfig agentConfigTemplate( + String key, + LDContext context, + AIAgentConfigDefault defaultValue); + + /** + * Retrieves a judge AI Config with Mustache placeholders left intact (no interpolation). Useful + * for auditing judge prompt templates. + * + * @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 + * @return the judge config with raw (non-interpolated) message content, never {@code null} + */ + AIJudgeConfig judgeConfigTemplate( + String key, + LDContext context, + AIJudgeConfigDefault defaultValue); + /** * Reconstructs a tracker from a resumption token, preserving the original run's identity. *

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 b6df8bed..140f7c7f 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 @@ -50,6 +50,9 @@ public final class LDAIClientImpl implements LDAIClient { 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 String TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE = "$ld:ai:usage:completion-config-template"; + private static final String TRACK_USAGE_AGENT_CONFIG_TEMPLATE = "$ld:ai:usage:agent-config-template"; + private static final String TRACK_USAGE_JUDGE_CONFIG_TEMPLATE = "$ld:ai:usage:judge-config-template"; private static final String TRACK_USAGE_AGENT_GRAPH = "$ld:ai:usage:agent-graph"; private static final LDContext INIT_TRACK_CONTEXT = LDContext @@ -100,7 +103,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, Mode.COMPLETION, variables); + return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, variables, true); } @Override @@ -148,7 +151,40 @@ 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, Mode.JUDGE, variables); + return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, variables, true); + } + + @Override + public AICompletionConfig completionConfigTemplate( + String key, + LDContext context, + AICompletionConfigDefault defaultValue) { + client.trackMetric(TRACK_USAGE_COMPLETION_CONFIG_TEMPLATE, context, LDValue.of(key), 1); + AICompletionConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AICompletionConfigDefault.disabled(); + return (AICompletionConfig) evaluate(key, context, effectiveDefault, Mode.COMPLETION, null, false); + } + + @Override + public AIAgentConfig agentConfigTemplate( + String key, + LDContext context, + AIAgentConfigDefault defaultValue) { + client.trackMetric(TRACK_USAGE_AGENT_CONFIG_TEMPLATE, context, LDValue.of(key), 1); + AIAgentConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled(); + return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, null, false); + } + + @Override + public AIJudgeConfig judgeConfigTemplate( + String key, + LDContext context, + AIJudgeConfigDefault defaultValue) { + client.trackMetric(TRACK_USAGE_JUDGE_CONFIG_TEMPLATE, context, LDValue.of(key), 1); + AIJudgeConfigDefault effectiveDefault = + defaultValue != null ? defaultValue : AIJudgeConfigDefault.disabled(); + return (AIJudgeConfig) evaluate(key, context, effectiveDefault, Mode.JUDGE, null, false); } private AIAgentConfig evaluateAgent( @@ -161,7 +197,7 @@ private AIAgentConfig evaluateAgent( Map variables, String graphKey) { AIAgentConfigDefault effectiveDefault = defaultValue != null ? defaultValue : AIAgentConfigDefault.disabled(); - return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables, graphKey); + return (AIAgentConfig) evaluate(key, context, effectiveDefault, Mode.AGENT, variables, true, graphKey); } /** @@ -174,8 +210,9 @@ private AIConfig evaluate( LDContext context, AIConfigDefault defaultValue, Mode mode, - Map variables) { - return evaluate(key, context, defaultValue, mode, variables, null); + Map variables, + boolean interpolate) { + return evaluate(key, context, defaultValue, mode, variables, interpolate, null); } private AIConfig evaluate( @@ -184,6 +221,7 @@ private AIConfig evaluate( AIConfigDefault defaultValue, Mode mode, Map variables, + boolean interpolate, String graphKey) { LDValue value = client.jsonValueVariation(key, context, LDValue.ofNull()); @@ -191,7 +229,7 @@ private AIConfig evaluate( // 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, graphKey); + return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate, graphKey); } AIConfigFlagValue parsed = AIConfigParser.parse(value); @@ -201,10 +239,10 @@ private AIConfig evaluate( logger.warn( "AI Config mode mismatch for {}: expected {}, got {}. Returning default config.", key, mode.getWireValue(), flagMode.getWireValue()); - return buildConfigFromDefault(key, mode, defaultValue, context, variables, graphKey); + return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate, graphKey); } - return buildConfig(key, mode, parsed, context, variables, graphKey); + return buildConfig(key, mode, parsed, context, variables, interpolate, graphKey); } private AIConfig buildConfig( @@ -212,8 +250,9 @@ private AIConfig buildConfig( Mode mode, AIConfigFlagValue parsed, LDContext context, - Map variables) { - return buildConfig(key, mode, parsed, context, variables, null); + Map variables, + boolean interpolate) { + return buildConfig(key, mode, parsed, context, variables, interpolate, null); } private AIConfig buildConfig( @@ -222,6 +261,7 @@ private AIConfig buildConfig( AIConfigFlagValue parsed, LDContext context, Map variables, + boolean interpolate, String graphKey) { Supplier factory = trackerFactory( key, parsed.getVariationKey(), parsed.getVersion(), @@ -233,7 +273,8 @@ private AIConfig buildConfig( parsed.isEnabled(), parsed.getModel(), parsed.getProvider(), - interpolate(parsed.getInstructions(), variables, context), + interpolate ? interpolate(parsed.getInstructions(), variables, context) + : parsed.getInstructions(), parsed.getJudgeConfiguration(), parsed.getTools(), factory, @@ -244,7 +285,8 @@ private AIConfig buildConfig( parsed.isEnabled(), parsed.getModel(), parsed.getProvider(), - interpolateMessages(parsed.getMessages(), variables, context), + interpolate ? interpolateMessages(parsed.getMessages(), variables, context) + : parsed.getMessages(), parsed.getEvaluationMetricKey(), factory); case COMPLETION: @@ -254,7 +296,8 @@ private AIConfig buildConfig( parsed.isEnabled(), parsed.getModel(), parsed.getProvider(), - interpolateMessages(parsed.getMessages(), variables, context), + interpolate ? interpolateMessages(parsed.getMessages(), variables, context) + : parsed.getMessages(), parsed.getJudgeConfiguration(), parsed.getTools(), factory, @@ -264,15 +307,17 @@ 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. + * or cannot be evaluated. Prompt content is interpolated exactly as it is for an evaluated flag, + * unless {@code interpolate} is {@code false} (template mode). */ private AIConfig buildConfigFromDefault( String key, Mode mode, AIConfigDefault defaultValue, LDContext context, - Map variables) { - return buildConfigFromDefault(key, mode, defaultValue, context, variables, null); + Map variables, + boolean interpolate) { + return buildConfigFromDefault(key, mode, defaultValue, context, variables, interpolate, null); } private AIConfig buildConfigFromDefault( @@ -281,6 +326,7 @@ private AIConfig buildConfigFromDefault( AIConfigDefault defaultValue, LDContext context, Map variables, + boolean interpolate, String graphKey) { // Default configs still get real trackers — the configKey was requested even if no flag was found. // variationKey is null because no flag evaluation occurred. @@ -293,7 +339,8 @@ private AIConfig buildConfigFromDefault( agent.isEnabled(), agent.getModel(), agent.getProvider(), - interpolate(agent.getInstructions(), variables, context), + interpolate ? interpolate(agent.getInstructions(), variables, context) + : agent.getInstructions(), agent.getJudgeConfiguration(), agent.getTools(), factory, @@ -306,7 +353,8 @@ private AIConfig buildConfigFromDefault( judge.isEnabled(), judge.getModel(), judge.getProvider(), - interpolateMessages(judge.getMessages(), variables, context), + interpolate ? interpolateMessages(judge.getMessages(), variables, context) + : judge.getMessages(), judge.getEvaluationMetricKey(), factory); } @@ -318,7 +366,8 @@ private AIConfig buildConfigFromDefault( completion.isEnabled(), completion.getModel(), completion.getProvider(), - interpolateMessages(completion.getMessages(), variables, context), + interpolate ? interpolateMessages(completion.getMessages(), variables, context) + : completion.getMessages(), completion.getJudgeConfiguration(), completion.getTools(), factory, 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 e72816a6..3eea21aa 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 @@ -294,6 +294,156 @@ public void agentConfigsUsageCountExcludesNullEntries() { verify(client).trackMetric(eq("$ld:ai:usage:agent-configs"), eq(context), eq(LDValue.of(2)), eq(2.0)); } + // ---- Template config methods ---------------------------------------------- + + // Tracking events + + @Test + public void completionConfigTemplateFiresTemplateUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.completionConfigTemplate("my-key", context, null); + verify(client).trackMetric( + eq("$ld:ai:usage:completion-config-template"), eq(context), eq(LDValue.of("my-key")), eq(1.0)); + } + + @Test + public void agentConfigTemplateFiresTemplateUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.agentConfigTemplate("agent-key", context, null); + verify(client).trackMetric( + eq("$ld:ai:usage:agent-config-template"), eq(context), eq(LDValue.of("agent-key")), eq(1.0)); + } + + @Test + public void judgeConfigTemplateFiresTemplateUsageEvent() { + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.ofNull()); + ai.judgeConfigTemplate("judge-key", context, null); + verify(client).trackMetric( + eq("$ld:ai:usage:judge-config-template"), eq(context), eq(LDValue.of("judge-key")), eq(1.0)); + } + + // Placeholder preservation + + @Test + public void completionConfigTemplatePreservesPlaceholders() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"Hello {{name}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfig config = ai.completionConfigTemplate("key", context, null); + assertThat(config.getMessages().get(0).getContent(), is("Hello {{name}}")); + } + + @Test + public void agentConfigTemplatePreservesPlaceholders() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"You research {{topic}}\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIAgentConfig config = ai.agentConfigTemplate("key", context, null); + assertThat(config.getInstructions(), is("You research {{topic}}")); + } + + @Test + public void judgeConfigTemplatePreservesPlaceholders() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Rate {{response}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIJudgeConfig config = ai.judgeConfigTemplate("key", context, null); + assertThat(config.getMessages().get(0).getContent(), is("Rate {{response}}")); + } + + // ldctx non-interpolation + + @Test + public void completionConfigTemplateDoesNotInterpolateLdctx() { + 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.completionConfigTemplate("key", LDContext.create("ctx-123"), null); + assertThat(config.getMessages().get(0).getContent(), is("{{ldctx.key}}")); + } + + @Test + public void agentConfigTemplateDoesNotInterpolateLdctx() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"Hello {{ldctx.key}}\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIAgentConfig config = ai.agentConfigTemplate("key", LDContext.create("ctx-123"), null); + assertThat(config.getInstructions(), is("Hello {{ldctx.key}}")); + } + + @Test + public void judgeConfigTemplateDoesNotInterpolateLdctx() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"{{ldctx.key}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIJudgeConfig config = ai.judgeConfigTemplate("key", LDContext.create("ctx-123"), null); + assertThat(config.getMessages().get(0).getContent(), is("{{ldctx.key}}")); + } + + // Null default yields disabled config + + @Test + public void completionConfigTemplateNullDefaultYieldsDisabled() { + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + AICompletionConfig config = ai.completionConfigTemplate("key", context, null); + assertThat(config.isEnabled(), is(false)); + } + + @Test + public void agentConfigTemplateNullDefaultYieldsDisabled() { + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + AIAgentConfig config = ai.agentConfigTemplate("key", context, null); + assertThat(config.isEnabled(), is(false)); + } + + @Test + public void judgeConfigTemplateNullDefaultYieldsDisabled() { + when(client.jsonValueVariation(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(2, LDValue.class)); + AIJudgeConfig config = ai.judgeConfigTemplate("key", context, null); + assertThat(config.isEnabled(), is(false)); + } + + // Tracker non-null + + @Test + public void completionConfigTemplateHasTracker() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"messages\":[{\"role\":\"system\",\"content\":\"Hello {{name}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfig config = ai.completionConfigTemplate("key", context, null); + assertThat(config.createTracker(), is(notNullValue())); + } + + @Test + public void agentConfigTemplateHasTracker() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," + + "\"instructions\":\"You research {{topic}}\"}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIAgentConfig config = ai.agentConfigTemplate("key", context, null); + assertThat(config.createTracker(), is(notNullValue())); + } + + @Test + public void judgeConfigTemplateHasTracker() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"judge\"}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Rate {{response}}\"}]}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AIJudgeConfig config = ai.judgeConfigTemplate("key", context, null); + assertThat(config.createTracker(), is(notNullValue())); + } + private static Map variables() { return new HashMap<>(); }