From 17ccbc9506931cfe9643b4ff78dfa65a179ad693 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 18:56:09 +0000 Subject: [PATCH 1/9] test(server-ai): cover model event metadata Co-authored-by: Anthony Torns II --- .../sdk/server/ai/LDAIClientImplTest.java | 28 ++++++++++ .../ai/internal/AIConfigParserTest.java | 18 ++++++- .../internal/LDAIConfigTrackerImplTest.java | 51 +++++++++++++++++-- 3 files changed, 92 insertions(+), 5 deletions(-) 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 d2fa98cd..2840af00 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 @@ -134,6 +134,34 @@ public void completionConfigReturnsTypedConfigFromVariation() { assertThat(config.getMessages().get(0).getRole(), is(Message.Role.SYSTEM)); } + @Test + public void completionConfigPropagatesModelMetadataToTracker() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"model\":{\"name\":\"gpt-4\",\"modelKey\":\"custom-gpt\",\"modelVersion\":7}}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfig config = ai.completionConfig("key", context, null, null); + + assertThat(config.getModel().getModelKey(), is("custom-gpt")); + assertThat(config.getModel().getModelVersion(), is(7)); + assertThat(config.createTracker().getTrackData().getModelKey(), is("custom-gpt")); + assertThat(config.createTracker().getTrackData().getModelVersion(), is(7)); + } + + @Test + public void completionConfigDefaultsMissingModelMetadata() { + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," + + "\"model\":{\"name\":\"gpt-4\"}}"; + when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); + + AICompletionConfig config = ai.completionConfig("key", context, null, null); + + assertThat(config.getModel().getModelKey(), is(nullValue())); + assertThat(config.getModel().getModelVersion(), is(1)); + assertThat(config.createTracker().getTrackData().getModelKey(), is(nullValue())); + assertThat(config.createTracker().getTrackData().getModelVersion(), is(1)); + } + @Test public void interpolationExposesContextAsLdctx() { String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java index a38770a7..d5d8814f 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java @@ -22,7 +22,8 @@ public class AIConfigParserTest { public void parsesFullCompletionConfig() { LDValue value = LDValue.parse("{" + "\"_ldMeta\":{\"variationKey\":\"v1\",\"enabled\":true,\"version\":3,\"mode\":\"completion\"}," - + "\"model\":{\"name\":\"gpt-4\",\"parameters\":{\"temperature\":0.7,\"maxTokens\":100}," + + "\"model\":{\"name\":\"gpt-4\",\"modelKey\":\"custom-gpt\",\"modelVersion\":7," + + "\"parameters\":{\"temperature\":0.7,\"maxTokens\":100}," + "\"custom\":{\"team\":\"core\"}}," + "\"provider\":{\"name\":\"openai\"}," + "\"messages\":[{\"role\":\"system\",\"content\":\"You are {{persona}}.\"}," @@ -39,6 +40,8 @@ public void parsesFullCompletionConfig() { assertThat(parsed.getVersion(), is(3)); assertThat(parsed.getMode(), is(Mode.COMPLETION)); assertThat(parsed.getModel().getName(), is("gpt-4")); + assertThat(parsed.getModel().getModelKey(), is("custom-gpt")); + assertThat(parsed.getModel().getModelVersion(), is(7)); assertThat(parsed.getModel().getParameter("temperature"), is((Object) 0.7)); assertThat(parsed.getModel().getParameter("maxTokens"), is((Object) 100L)); assertThat(parsed.getModel().getCustom("team"), is((Object) "core")); @@ -53,6 +56,19 @@ public void parsesFullCompletionConfig() { assertThat(parsed.getTools().get("search").getType(), is("function")); } + @Test + public void modelMetadataDefaultsWhenMissingOrWrongType() { + AIConfigFlagValue missing = AIConfigParser.parse( + LDValue.parse("{\"model\":{\"name\":\"gpt-4\"}}")); + assertThat(missing.getModel().getModelKey(), is(nullValue())); + assertThat(missing.getModel().getModelVersion(), is(1)); + + AIConfigFlagValue wrongType = AIConfigParser.parse( + LDValue.parse("{\"model\":{\"modelKey\":3,\"modelVersion\":\"two\"}}")); + assertThat(wrongType.getModel().getModelKey(), is(nullValue())); + assertThat(wrongType.getModel().getModelVersion(), is(1)); + } + @Test public void parsesAgentInstructions() { LDValue value = LDValue.parse("{\"_ldMeta\":{\"enabled\":true,\"mode\":\"agent\"}," diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java index ffcadaa9..d504969e 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java @@ -61,6 +61,8 @@ public class LDAIConfigTrackerImplTest { private static final int VERSION = 3; private static final String MODEL_NAME = "gpt-4"; private static final String PROVIDER_NAME = "openai"; + private static final String MODEL_KEY = "custom-gpt"; + private static final int MODEL_VERSION = 7; @Before public void setUp() { @@ -71,9 +73,14 @@ public void setUp() { } private LDAIConfigTrackerImpl makeTracker(String variationKey) { + return makeTracker(variationKey, MODEL_KEY, MODEL_VERSION); + } + + private LDAIConfigTrackerImpl makeTracker( + String variationKey, String modelKey, int modelVersion) { return new LDAIConfigTrackerImpl( client, RUN_ID, CONFIG_KEY, variationKey, VERSION, - MODEL_NAME, PROVIDER_NAME, CONTEXT, null, logger); + MODEL_NAME, PROVIDER_NAME, modelKey, modelVersion, CONTEXT, null, logger); } private List warnings() { @@ -98,6 +105,8 @@ private LDValue baseExpectedData() { .put("version", VERSION) .put("modelName", MODEL_NAME) .put("providerName", PROVIDER_NAME) + .put("modelKey", MODEL_KEY) + .put("modelVersion", MODEL_VERSION) .build(); } @@ -112,6 +121,8 @@ public void getTrackDataReturnsCorrectFields() { assertThat(data.getVersion(), is(VERSION)); assertThat(data.getModelName(), is(MODEL_NAME)); assertThat(data.getProviderName(), is(PROVIDER_NAME)); + assertThat(data.getModelKey(), is(MODEL_KEY)); + assertThat(data.getModelVersion(), is(MODEL_VERSION)); assertThat(data.getGraphKey(), is(nullValue())); } @@ -139,6 +150,12 @@ public void resumptionTokenRoundTrips() throws Exception { assertThat(d.getGraphKey(), is(nullValue())); } + @Test + public void resumptionTokenExcludesModelMetadata() { + LDAIConfigTrackerImpl other = makeTracker(VARIATION_KEY, "different-model", 99); + assertThat(other.getResumptionToken(), is(tracker.getResumptionToken())); + } + @Test public void fromResumptionTokenRestoresCorrectFields() { String token = tracker.getResumptionToken(); @@ -151,6 +168,8 @@ public void fromResumptionTokenRestoresCorrectFields() { assertThat(data.getVersion(), is(VERSION)); assertThat(data.getModelName(), is("")); // not in token assertThat(data.getProviderName(), is("")); // not in token + assertThat(data.getModelKey(), is(nullValue())); // not in token + assertThat(data.getModelVersion(), is(1)); // not in token } // ---- trackDuration -------------------------------------------------------- @@ -650,13 +669,37 @@ public void variationKeyIncludedInPayloadWhenPresent() { assertThat(dataCaptor.getValue().get("variationKey").stringValue(), is(VARIATION_KEY)); } + // ---- model metadata inclusion -------------------------------------------- + + @Test + public void modelMetadataIncludedInPayloadWhenPresent() { + tracker.trackSuccess(); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(LDValue.class); + verify(client).trackMetric(eq("$ld:ai:generation:success"), any(), dataCaptor.capture(), anyDouble()); + assertThat(dataCaptor.getValue().get("modelKey").stringValue(), is(MODEL_KEY)); + assertThat(dataCaptor.getValue().get("modelVersion").intValue(), is(MODEL_VERSION)); + } + + @Test + public void blankModelKeyOmittedFromTrackDataAndPayload() { + LDAIConfigTrackerImpl t = makeTracker(VARIATION_KEY, " ", MODEL_VERSION); + assertThat(t.getTrackData().getModelKey(), is(nullValue())); + assertThat(t.getTrackData().toLDValue().get("modelKey").isNull(), is(true)); + + t.trackSuccess(); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(LDValue.class); + verify(client).trackMetric(eq("$ld:ai:generation:success"), any(), dataCaptor.capture(), anyDouble()); + assertThat(dataCaptor.getValue().get("modelKey").isNull(), is(true)); + assertThat(dataCaptor.getValue().get("modelVersion").intValue(), is(MODEL_VERSION)); + } + // ---- graphKey inclusion --------------------------------------------------- @Test public void graphKeyIncludedInPayloadWhenSet() { LDAIConfigTrackerImpl t = new LDAIConfigTrackerImpl( client, RUN_ID, CONFIG_KEY, VARIATION_KEY, VERSION, - MODEL_NAME, PROVIDER_NAME, CONTEXT, "my-graph", logger); + MODEL_NAME, PROVIDER_NAME, MODEL_KEY, MODEL_VERSION, CONTEXT, "my-graph", logger); t.trackSuccess(); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(LDValue.class); verify(client).trackMetric(eq("$ld:ai:generation:success"), any(), dataCaptor.capture(), anyDouble()); @@ -727,12 +770,12 @@ public void trackSuccessAtMostOnceUnderConcurrency() throws InterruptedException @Test(expected = NullPointerException.class) public void constructorRejectsNullClient() { new LDAIConfigTrackerImpl(null, RUN_ID, CONFIG_KEY, VARIATION_KEY, VERSION, - MODEL_NAME, PROVIDER_NAME, CONTEXT, null, logger); + MODEL_NAME, PROVIDER_NAME, MODEL_KEY, MODEL_VERSION, CONTEXT, null, logger); } @Test(expected = NullPointerException.class) public void constructorRejectsNullContext() { new LDAIConfigTrackerImpl(client, RUN_ID, CONFIG_KEY, VARIATION_KEY, VERSION, - MODEL_NAME, PROVIDER_NAME, null, null, logger); + MODEL_NAME, PROVIDER_NAME, MODEL_KEY, MODEL_VERSION, null, null, logger); } } From 369ad38ba9837d98d0cafea128f92a3b1eab5e4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 18:58:57 +0000 Subject: [PATCH 2/9] feat(server-ai): stamp model metadata on events Co-authored-by: Anthony Torns II --- .../sdk/server/ai/LDAIClientImpl.java | 4 ++ .../server/ai/datamodel/LDAIConfigTypes.java | 62 +++++++++++++++++-- .../ai/datamodel/LDAITrackingTypes.java | 34 +++++++++- .../server/ai/internal/AIConfigParser.java | 4 ++ .../ai/internal/LDAIConfigTrackerImpl.java | 27 +++++++- 5 files changed, 123 insertions(+), 8 deletions(-) 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 140f7c7f..71569544 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 @@ -400,6 +400,8 @@ private Supplier trackerFactory( String graphKey) { String modelName = model != null && model.getName() != null ? model.getName() : ""; String providerName = provider != null && provider.getName() != null ? provider.getName() : ""; + String modelKey = model != null ? model.getModelKey() : null; + int modelVersion = model != null ? model.getModelVersion() : 1; int ver = version != null ? version : 1; return () -> new LDAIConfigTrackerImpl( client, @@ -409,6 +411,8 @@ private Supplier trackerFactory( ver, modelName, providerName, + modelKey, + modelVersion, context, graphKey, logger); diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java index e6ccf122..d2ff9e1c 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java @@ -214,11 +214,20 @@ public String toString() { */ public static final class Model { private final String name; + private final String modelKey; + private final int modelVersion; private final Map parameters; private final Map custom; - private Model(String name, Map parameters, Map custom) { + private Model( + String name, + String modelKey, + int modelVersion, + Map parameters, + Map custom) { this.name = name; + this.modelKey = modelKey; + this.modelVersion = modelVersion; this.parameters = parameters; this.custom = custom; } @@ -232,6 +241,24 @@ public String getName() { return name; } + /** + * Returns the stable key of the model configuration. + * + * @return the model key, or {@code null} if none was specified + */ + public String getModelKey() { + return modelKey; + } + + /** + * Returns the version of the model configuration. + * + * @return the model version; defaults to {@code 1} + */ + public int getModelVersion() { + return modelVersion; + } + /** * Returns the model-specific parameters as an unmodifiable map. * @@ -290,18 +317,21 @@ public boolean equals(Object o) { } Model other = (Model) o; return Objects.equals(name, other.name) + && Objects.equals(modelKey, other.modelKey) + && modelVersion == other.modelVersion && parameters.equals(other.parameters) && custom.equals(other.custom); } @Override public int hashCode() { - return Objects.hash(name, parameters, custom); + return Objects.hash(name, modelKey, modelVersion, parameters, custom); } @Override public String toString() { - return "Model{name=" + name + ", parameters=" + parameters + ", custom=" + custom + '}'; + return "Model{name=" + name + ", modelKey=" + modelKey + ", modelVersion=" + modelVersion + + ", parameters=" + parameters + ", custom=" + custom + '}'; } /** @@ -309,6 +339,8 @@ public String toString() { */ public static final class Builder { private final String name; + private String modelKey; + private int modelVersion = 1; private Map parameters; private Map custom; @@ -316,6 +348,28 @@ private Builder(String name) { this.name = name; } + /** + * Sets the stable key of the model configuration. + * + * @param modelKey the model key; may be {@code null} + * @return this builder + */ + public Builder modelKey(String modelKey) { + this.modelKey = modelKey; + return this; + } + + /** + * Sets the version of the model configuration. + * + * @param modelVersion the model version + * @return this builder + */ + public Builder modelVersion(int modelVersion) { + this.modelVersion = modelVersion; + return this; + } + /** * Sets the model-specific parameters. The map is copied defensively. * @@ -350,7 +404,7 @@ public Model build() { Map cust = custom == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(custom)); - return new Model(name, params, cust); + return new Model(name, modelKey, modelVersion, params, cust); } } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAITrackingTypes.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAITrackingTypes.java index fbae0264..95a07a9d 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAITrackingTypes.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAITrackingTypes.java @@ -599,6 +599,8 @@ public static final class TrackData { private final int version; private final String modelName; private final String providerName; + private final String modelKey; + private final int modelVersion; private final String graphKey; /** @@ -610,6 +612,8 @@ public static final class TrackData { * @param version the config version * @param modelName the model name, or empty string when unknown * @param providerName the provider name, or empty string when unknown + * @param modelKey the stable model key, or {@code null} when unknown + * @param modelVersion the model version * @param graphKey the agent graph key, or {@code null} when not part of a graph */ public TrackData( @@ -619,6 +623,8 @@ public TrackData( int version, String modelName, String providerName, + String modelKey, + int modelVersion, String graphKey) { this.runId = Objects.requireNonNull(runId, "runId"); this.configKey = Objects.requireNonNull(configKey, "configKey"); @@ -626,6 +632,8 @@ public TrackData( this.version = version; this.modelName = modelName == null ? "" : modelName; this.providerName = providerName == null ? "" : providerName; + this.modelKey = modelKey == null || modelKey.trim().isEmpty() ? null : modelKey; + this.modelVersion = modelVersion; this.graphKey = graphKey; } @@ -683,6 +691,24 @@ public String getProviderName() { return providerName; } + /** + * Returns the stable model key. + * + * @return the model key, or {@code null} when unknown + */ + public String getModelKey() { + return modelKey; + } + + /** + * Returns the model version. + * + * @return the model version + */ + public int getModelVersion() { + return modelVersion; + } + /** * Returns the agent graph key. * @@ -695,7 +721,7 @@ public String getGraphKey() { /** * Builds an {@link LDValue} representation of this track data using camelCase keys. *

- * {@code variationKey} and {@code graphKey} are omitted when {@code null}. + * {@code variationKey}, {@code modelKey}, and {@code graphKey} are omitted when {@code null}. * * @return an {@link LDValue} object containing all non-null fields */ @@ -705,10 +731,14 @@ public LDValue toLDValue() { .put("configKey", configKey) .put("version", version) .put("modelName", modelName) - .put("providerName", providerName); + .put("providerName", providerName) + .put("modelVersion", modelVersion); if (variationKey != null) { b.put("variationKey", variationKey); } + if (modelKey != null) { + b.put("modelKey", modelKey); + } if (graphKey != null) { b.put("graphKey", graphKey); } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 40568454..4360c3e1 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -80,7 +80,11 @@ static Model parseModel(LDValue model) { if (model == null || model.getType() != LDValueType.OBJECT) { return null; } + LDValue modelVersion = model.get("modelVersion"); + int version = modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1; return Model.builder(asStringOrNull(model.get("name"))) + .modelKey(asStringOrNull(model.get("modelKey"))) + .modelVersion(version) .parameters(LDValueConverter.toMap(model.get("parameters"))) .custom(LDValueConverter.toMap(model.get("custom"))) .build(); diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImpl.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImpl.java index 766d59e7..06f453fa 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImpl.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImpl.java @@ -58,6 +58,8 @@ public final class LDAIConfigTrackerImpl implements LDAIConfigTracker { private final int version; private final String modelName; // empty string when unknown private final String providerName; // empty string when unknown + private final String modelKey; // nullable + private final int modelVersion; private final String graphKey; // nullable // Computed once at construction @@ -86,6 +88,8 @@ public final class LDAIConfigTrackerImpl implements LDAIConfigTracker { * @param version the config version * @param modelName the model name, or empty string when unknown * @param providerName the provider name, or empty string when unknown + * @param modelKey the stable model key, or {@code null} when unknown + * @param modelVersion the model version * @param context the evaluation context; must not be {@code null} * @param graphKey the agent graph key, or {@code null} when not part of a graph * @param logger the logger; must not be {@code null} @@ -98,6 +102,8 @@ public LDAIConfigTrackerImpl( int version, String modelName, String providerName, + String modelKey, + int modelVersion, LDContext context, String graphKey, LDLogger logger) { @@ -108,6 +114,8 @@ public LDAIConfigTrackerImpl( this.version = version; this.modelName = modelName == null ? "" : modelName; this.providerName = providerName == null ? "" : providerName; + this.modelKey = modelKey == null || modelKey.trim().isEmpty() ? null : modelKey; + this.modelVersion = modelVersion; this.context = Objects.requireNonNull(context, "context"); this.graphKey = graphKey; this.logger = Objects.requireNonNull(logger, "logger"); @@ -137,6 +145,8 @@ public static LDAIConfigTrackerImpl fromResumptionToken( d.getVersion(), "", // modelName not carried in token "", // providerName not carried in token + null, // modelKey not carried in token + 1, // modelVersion not carried in token context, d.getGraphKey(), logger); @@ -144,7 +154,16 @@ public static LDAIConfigTrackerImpl fromResumptionToken( @Override public TrackData getTrackData() { - return new TrackData(runId, configKey, variationKey, version, modelName, providerName, graphKey); + return new TrackData( + runId, + configKey, + variationKey, + version, + modelName, + providerName, + modelKey, + modelVersion, + graphKey); } @Override @@ -377,10 +396,14 @@ private ObjectBuilder baseData() { .put("configKey", configKey) .put("version", version) .put("modelName", modelName) - .put("providerName", providerName); + .put("providerName", providerName) + .put("modelVersion", modelVersion); if (variationKey != null) { b.put("variationKey", variationKey); } + if (modelKey != null) { + b.put("modelKey", modelKey); + } if (graphKey != null) { b.put("graphKey", graphKey); } From 95636f63c2f52e19505898820901c862161be800 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 18:59:55 +0000 Subject: [PATCH 3/9] test(server-ai): expect model metadata on all events Co-authored-by: Anthony Torns II --- .../sdk/server/ai/internal/LDAIConfigTrackerImplTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java index d504969e..ecc226dd 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java @@ -387,6 +387,7 @@ public void trackToolCallEmitsOnEveryCall() { .put("runId", RUN_ID).put("configKey", CONFIG_KEY) .put("variationKey", VARIATION_KEY).put("version", VERSION) .put("modelName", MODEL_NAME).put("providerName", PROVIDER_NAME) + .put("modelKey", MODEL_KEY).put("modelVersion", MODEL_VERSION) .put("toolKey", "search") .build(); @@ -400,6 +401,7 @@ public void trackToolCallEmitsOnEveryCall() { .put("runId", RUN_ID).put("configKey", CONFIG_KEY) .put("variationKey", VARIATION_KEY).put("version", VERSION) .put("modelName", MODEL_NAME).put("providerName", PROVIDER_NAME) + .put("modelKey", MODEL_KEY).put("modelVersion", MODEL_VERSION) .put("toolKey", "fetch") .build(); verify(client, times(1)).trackMetric( @@ -434,6 +436,7 @@ public void trackJudgeResultEmitsWhenSampledAndSucceeded() { .put("runId", RUN_ID).put("configKey", CONFIG_KEY) .put("variationKey", VARIATION_KEY).put("version", VERSION) .put("modelName", MODEL_NAME).put("providerName", PROVIDER_NAME) + .put("modelKey", MODEL_KEY).put("modelVersion", MODEL_VERSION) .put("judgeConfigKey", "my-judge") .build(); From 1ad8617c794a62c9d30a32a61024979d47e7ea31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 19:01:32 +0000 Subject: [PATCH 4/9] test(server-ai): cover all blank model keys Co-authored-by: Anthony Torns II --- .../server/ai/internal/LDAIConfigTrackerImplTest.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java index ecc226dd..6455f2a7 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java @@ -685,10 +685,14 @@ public void modelMetadataIncludedInPayloadWhenPresent() { @Test public void blankModelKeyOmittedFromTrackDataAndPayload() { - LDAIConfigTrackerImpl t = makeTracker(VARIATION_KEY, " ", MODEL_VERSION); - assertThat(t.getTrackData().getModelKey(), is(nullValue())); - assertThat(t.getTrackData().toLDValue().get("modelKey").isNull(), is(true)); + for (String blankModelKey : Arrays.asList(null, "", " ")) { + LDAIConfigTrackerImpl blankTracker = + makeTracker(VARIATION_KEY, blankModelKey, MODEL_VERSION); + assertThat(blankTracker.getTrackData().getModelKey(), is(nullValue())); + assertThat(blankTracker.getTrackData().toLDValue().get("modelKey").isNull(), is(true)); + } + LDAIConfigTrackerImpl t = makeTracker(VARIATION_KEY, " ", MODEL_VERSION); t.trackSuccess(); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(LDValue.class); verify(client).trackMetric(eq("$ld:ai:generation:success"), any(), dataCaptor.capture(), anyDouble()); From 62f973d40d6dc74a785ae1dc81a22e2c8d47689c Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 14 Jul 2026 12:28:39 -0700 Subject: [PATCH 5/9] fix(server-ai): normalize blank modelKey during parsing, strengthen token test Model.getModelKey() previously returned an empty string for a blank-but-present modelKey payload value, contradicting its own Javadoc contract and diverging from TrackData/tracker normalization. Trim it to null in AIConfigParser like other string fields. Also strengthen resumptionTokenExcludesModelMetadata to decode the token and assert the wire format has no modelKey/modelVersion, rather than relying solely on token equality across two trackers. Co-authored-by: Cursor --- .../sdk/server/ai/internal/AIConfigParser.java | 2 +- .../sdk/server/ai/internal/AIConfigParserTest.java | 4 ++++ .../server/ai/internal/LDAIConfigTrackerImplTest.java | 11 ++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 4360c3e1..9fbbf889 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -83,7 +83,7 @@ static Model parseModel(LDValue model) { LDValue modelVersion = model.get("modelVersion"); int version = modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1; return Model.builder(asStringOrNull(model.get("name"))) - .modelKey(asStringOrNull(model.get("modelKey"))) + .modelKey(trimToNull(asStringOrNull(model.get("modelKey")))) .modelVersion(version) .parameters(LDValueConverter.toMap(model.get("parameters"))) .custom(LDValueConverter.toMap(model.get("custom"))) diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java index d5d8814f..5459f586 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java @@ -67,6 +67,10 @@ public void modelMetadataDefaultsWhenMissingOrWrongType() { LDValue.parse("{\"model\":{\"modelKey\":3,\"modelVersion\":\"two\"}}")); assertThat(wrongType.getModel().getModelKey(), is(nullValue())); assertThat(wrongType.getModel().getModelVersion(), is(1)); + + AIConfigFlagValue blankKey = AIConfigParser.parse( + LDValue.parse("{\"model\":{\"modelKey\":\" \"}}")); + assertThat(blankKey.getModel().getModelKey(), is(nullValue())); } @Test diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java index 6455f2a7..02261893 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImplTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; @@ -152,8 +153,16 @@ public void resumptionTokenRoundTrips() throws Exception { @Test public void resumptionTokenExcludesModelMetadata() { + String token = tracker.getResumptionToken(); + String decodedJson = new String( + java.util.Base64.getUrlDecoder().decode(token), java.nio.charset.StandardCharsets.UTF_8); + assertThat(decodedJson, not(containsString("modelKey"))); + assertThat(decodedJson, not(containsString("modelVersion"))); + + // Changing only model metadata must not change the encoded token, since decode() has no + // model fields to carry it and encode() never receives them. LDAIConfigTrackerImpl other = makeTracker(VARIATION_KEY, "different-model", 99); - assertThat(other.getResumptionToken(), is(tracker.getResumptionToken())); + assertThat(other.getResumptionToken(), is(token)); } @Test From 1ec385ad0e9208bb5cd17837dd006fbb65cdbdbd Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Wed, 15 Jul 2026 16:47:17 -0700 Subject: [PATCH 6/9] Read modelKey/modelVersion from _ldMeta, matching gonfalon PR 67230 Gonfalon moved these two fields from the payload's model object to _ldMeta (launchdarkly/gonfalon#67230) to avoid modelVersion reading as the underlying LLM's own version. Field names are unchanged; only the JSON location moves. Model's public getters and the tracker's stamped event fields are untouched. Co-Authored-By: Claude Fable 5 --- .../sdk/server/ai/internal/AIConfigParser.java | 18 ++++++++++++------ .../sdk/server/ai/LDAIClientImplTest.java | 5 +++-- .../server/ai/internal/AIConfigParserTest.java | 9 +++++---- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 9fbbf889..56383150 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -42,8 +42,9 @@ public static AIConfigFlagValue parse(LDValue value) { return builder.build(); } - parseMeta(value.get("_ldMeta"), builder); - builder.model(parseModel(value.get("model"))); + LDValue meta = value.get("_ldMeta"); + parseMeta(meta, builder); + builder.model(parseModel(value.get("model"), meta)); builder.provider(parseProvider(value.get("provider"))); builder.messages(parseMessages(value.get("messages"))); builder.instructions(asStringOrNull(value.get("instructions"))); @@ -71,19 +72,24 @@ private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { } /** - * Parses a {@code model} object. + * Parses a {@code model} object. {@code modelKey}/{@code modelVersion} live under + * {@code _ldMeta} rather than {@code model} itself, to avoid reading as the underlying LLM's + * own version (e.g. "GPT-5.4"). * * @param model the model value + * @param meta the {@code _ldMeta} value, for modelKey/modelVersion * @return a {@link Model}, or {@code null} if the value is not an object */ - static Model parseModel(LDValue model) { + static Model parseModel(LDValue model, LDValue meta) { if (model == null || model.getType() != LDValueType.OBJECT) { return null; } - LDValue modelVersion = model.get("modelVersion"); + boolean hasMeta = meta != null && meta.getType() == LDValueType.OBJECT; + LDValue modelVersion = hasMeta ? meta.get("modelVersion") : LDValue.ofNull(); int version = modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1; + String modelKey = hasMeta ? asStringOrNull(meta.get("modelKey")) : null; return Model.builder(asStringOrNull(model.get("name"))) - .modelKey(trimToNull(asStringOrNull(model.get("modelKey")))) + .modelKey(trimToNull(modelKey)) .modelVersion(version) .parameters(LDValueConverter.toMap(model.get("parameters"))) .custom(LDValueConverter.toMap(model.get("custom"))) 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 2840af00..40046711 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 @@ -136,8 +136,9 @@ public void completionConfigReturnsTypedConfigFromVariation() { @Test public void completionConfigPropagatesModelMetadataToTracker() { - String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"}," - + "\"model\":{\"name\":\"gpt-4\",\"modelKey\":\"custom-gpt\",\"modelVersion\":7}}"; + String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"," + + "\"modelKey\":\"custom-gpt\",\"modelVersion\":7}," + + "\"model\":{\"name\":\"gpt-4\"}}"; when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json)); AICompletionConfig config = ai.completionConfig("key", context, null, null); diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java index 5459f586..117f0bd7 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java @@ -21,8 +21,9 @@ public class AIConfigParserTest { @Test public void parsesFullCompletionConfig() { LDValue value = LDValue.parse("{" - + "\"_ldMeta\":{\"variationKey\":\"v1\",\"enabled\":true,\"version\":3,\"mode\":\"completion\"}," - + "\"model\":{\"name\":\"gpt-4\",\"modelKey\":\"custom-gpt\",\"modelVersion\":7," + + "\"_ldMeta\":{\"variationKey\":\"v1\",\"enabled\":true,\"version\":3,\"mode\":\"completion\"," + + "\"modelKey\":\"custom-gpt\",\"modelVersion\":7}," + + "\"model\":{\"name\":\"gpt-4\"," + "\"parameters\":{\"temperature\":0.7,\"maxTokens\":100}," + "\"custom\":{\"team\":\"core\"}}," + "\"provider\":{\"name\":\"openai\"}," @@ -64,12 +65,12 @@ public void modelMetadataDefaultsWhenMissingOrWrongType() { assertThat(missing.getModel().getModelVersion(), is(1)); AIConfigFlagValue wrongType = AIConfigParser.parse( - LDValue.parse("{\"model\":{\"modelKey\":3,\"modelVersion\":\"two\"}}")); + LDValue.parse("{\"_ldMeta\":{\"modelKey\":3,\"modelVersion\":\"two\"},\"model\":{}}")); assertThat(wrongType.getModel().getModelKey(), is(nullValue())); assertThat(wrongType.getModel().getModelVersion(), is(1)); AIConfigFlagValue blankKey = AIConfigParser.parse( - LDValue.parse("{\"model\":{\"modelKey\":\" \"}}")); + LDValue.parse("{\"_ldMeta\":{\"modelKey\":\" \"},\"model\":{}}")); assertThat(blankKey.getModel().getModelKey(), is(nullValue())); } From eb440b097818d705c8107e7828e87b4bfd562411 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Thu, 16 Jul 2026 14:53:57 -0700 Subject: [PATCH 7/9] Move modelKey/modelVersion parsing from parseModel into parseMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the same fix applied to the .NET SDK (dotnet-core PR #308): parseModel reaching into _ldMeta mixed concerns — it should only parse the model object. parseMeta now returns a small ModelIdentity holder (modelKey, modelVersion) alongside populating the builder with the other _ldMeta fields, and parseModel takes them as plain parameters instead of fetching _ldMeta itself. No behavior change; full suite + checkstyle pass. Co-Authored-By: Claude Fable 5 --- .../server/ai/internal/AIConfigParser.java | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 56383150..e685a921 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -43,8 +43,8 @@ public static AIConfigFlagValue parse(LDValue value) { } LDValue meta = value.get("_ldMeta"); - parseMeta(meta, builder); - builder.model(parseModel(value.get("model"), meta)); + ModelIdentity modelIdentity = parseMeta(meta, builder); + builder.model(parseModel(value.get("model"), modelIdentity.modelKey, modelIdentity.modelVersion)); builder.provider(parseProvider(value.get("provider"))); builder.messages(parseMessages(value.get("messages"))); builder.instructions(asStringOrNull(value.get("instructions"))); @@ -55,9 +55,25 @@ public static AIConfigFlagValue parse(LDValue value) { return builder.build(); } - private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { + /** + * Holds {@code modelKey}/{@code modelVersion} as parsed from {@code _ldMeta} by + * {@link #parseMeta}, for {@link #parseModel} to attach to the {@link Model} it builds. + * These fields live under {@code _ldMeta} rather than {@code model} itself, to avoid + * {@code modelVersion} reading as the underlying LLM's own version (e.g. "GPT-5.4"). + */ + private static final class ModelIdentity { + final String modelKey; + final int modelVersion; + + ModelIdentity(String modelKey, int modelVersion) { + this.modelKey = modelKey; + this.modelVersion = modelVersion; + } + } + + private static ModelIdentity parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { if (meta == null || meta.getType() != LDValueType.OBJECT) { - return; + return new ModelIdentity(null, 1); } LDValue enabled = meta.get("enabled"); if (enabled.getType() == LDValueType.BOOLEAN) { @@ -69,28 +85,29 @@ private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { builder.version(version.intValue()); } builder.mode(Mode.fromWireValue(asStringOrNull(meta.get("mode")))); + + LDValue modelVersion = meta.get("modelVersion"); + int parsedModelVersion = modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1; + String modelKey = trimToNull(asStringOrNull(meta.get("modelKey"))); + return new ModelIdentity(modelKey, parsedModelVersion); } /** - * Parses a {@code model} object. {@code modelKey}/{@code modelVersion} live under - * {@code _ldMeta} rather than {@code model} itself, to avoid reading as the underlying LLM's - * own version (e.g. "GPT-5.4"). + * Parses a {@code model} object, attaching the {@code modelKey}/{@code modelVersion} already + * parsed from {@code _ldMeta} by {@link #parseMeta}. * * @param model the model value - * @param meta the {@code _ldMeta} value, for modelKey/modelVersion + * @param modelKey the model key parsed from {@code _ldMeta}, or {@code null} + * @param modelVersion the model version parsed from {@code _ldMeta}, defaulting to 1 * @return a {@link Model}, or {@code null} if the value is not an object */ - static Model parseModel(LDValue model, LDValue meta) { + static Model parseModel(LDValue model, String modelKey, int modelVersion) { if (model == null || model.getType() != LDValueType.OBJECT) { return null; } - boolean hasMeta = meta != null && meta.getType() == LDValueType.OBJECT; - LDValue modelVersion = hasMeta ? meta.get("modelVersion") : LDValue.ofNull(); - int version = modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1; - String modelKey = hasMeta ? asStringOrNull(meta.get("modelKey")) : null; return Model.builder(asStringOrNull(model.get("name"))) - .modelKey(trimToNull(modelKey)) - .modelVersion(version) + .modelKey(modelKey) + .modelVersion(modelVersion) .parameters(LDValueConverter.toMap(model.get("parameters"))) .custom(LDValueConverter.toMap(model.get("custom"))) .build(); From 7a1e877b077664c6af04ffa125c500e4c5271b32 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Thu, 16 Jul 2026 15:16:02 -0700 Subject: [PATCH 8/9] Store modelKey/modelVersion alongside the rest of _ldMeta, not a one-off holder Follow-up to eb440b0: rather than a separate ModelIdentity class, modelKey/modelVersion are now fields on AIConfigFlagValue.Builder alongside enabled/variationKey/version/mode, set by parseMeta the same way as the others. parseModel still only touches the model object, reading modelKey/modelVersion from the builder as plain parameters. No behavior change; full suite + checkstyle pass. Co-Authored-By: Claude Fable 5 --- .../server/ai/internal/AIConfigFlagValue.java | 68 +++++++++++++++++++ .../server/ai/internal/AIConfigParser.java | 35 ++++------ 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java index e8ba2373..85a13885 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java @@ -37,6 +37,8 @@ public final class AIConfigFlagValue { private final Map tools; private final JudgeConfiguration judgeConfiguration; private final String evaluationMetricKey; + private final String modelKey; + private final Integer modelVersion; private AIConfigFlagValue(Builder b) { this.enabled = b.enabled; @@ -50,6 +52,8 @@ private AIConfigFlagValue(Builder b) { this.tools = b.tools == null ? null : Collections.unmodifiableMap(b.tools); this.judgeConfiguration = b.judgeConfiguration; this.evaluationMetricKey = b.evaluationMetricKey; + this.modelKey = b.modelKey; + this.modelVersion = b.modelVersion; } /** @@ -97,6 +101,26 @@ public Mode getMode() { return mode; } + /** + * Returns the {@code _ldMeta.modelKey}. Lives under {@code _ldMeta} rather than {@code model} + * itself, to avoid {@code modelVersion} reading as the underlying LLM's own version + * (e.g. "GPT-5.4"). + * + * @return the model key, or {@code null} if absent + */ + public String getModelKey() { + return modelKey; + } + + /** + * Returns the {@code _ldMeta.modelVersion}. + * + * @return the model version, or {@code null} if absent + */ + public Integer getModelVersion() { + return modelVersion; + } + /** * Returns the model configuration. * @@ -184,6 +208,8 @@ public static final class Builder { private Map tools; private JudgeConfiguration judgeConfiguration; private String evaluationMetricKey; + private String modelKey; + private Integer modelVersion; private Builder() { } @@ -221,6 +247,48 @@ public Builder version(Integer v) { return this; } + /** + * Sets the model key, parsed from {@code _ldMeta.modelKey}. + * + * @param v the model key + * @return this builder + */ + public Builder modelKey(String v) { + this.modelKey = v; + return this; + } + + /** + * Sets the model version, parsed from {@code _ldMeta.modelVersion}. + * + * @param v the model version + * @return this builder + */ + public Builder modelVersion(Integer v) { + this.modelVersion = v; + return this; + } + + /** + * Returns the model key set so far, for {@link AIConfigParser} to pass into + * {@link AIConfigParser#parseModel} alongside the {@code model} value. + * + * @return the model key, or {@code null} if unset + */ + String getModelKey() { + return modelKey; + } + + /** + * Returns the model version set so far, for {@link AIConfigParser} to pass into + * {@link AIConfigParser#parseModel} alongside the {@code model} value. + * + * @return the model version, or {@code null} if unset + */ + Integer getModelVersion() { + return modelVersion; + } + /** * Sets the mode. * diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index e685a921..983f0c66 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -43,8 +43,9 @@ public static AIConfigFlagValue parse(LDValue value) { } LDValue meta = value.get("_ldMeta"); - ModelIdentity modelIdentity = parseMeta(meta, builder); - builder.model(parseModel(value.get("model"), modelIdentity.modelKey, modelIdentity.modelVersion)); + parseMeta(meta, builder); + Integer modelVersion = builder.getModelVersion(); + builder.model(parseModel(value.get("model"), builder.getModelKey(), modelVersion == null ? 1 : modelVersion)); builder.provider(parseProvider(value.get("provider"))); builder.messages(parseMessages(value.get("messages"))); builder.instructions(asStringOrNull(value.get("instructions"))); @@ -55,25 +56,14 @@ public static AIConfigFlagValue parse(LDValue value) { return builder.build(); } - /** - * Holds {@code modelKey}/{@code modelVersion} as parsed from {@code _ldMeta} by - * {@link #parseMeta}, for {@link #parseModel} to attach to the {@link Model} it builds. - * These fields live under {@code _ldMeta} rather than {@code model} itself, to avoid - * {@code modelVersion} reading as the underlying LLM's own version (e.g. "GPT-5.4"). - */ - private static final class ModelIdentity { - final String modelKey; - final int modelVersion; - - ModelIdentity(String modelKey, int modelVersion) { - this.modelKey = modelKey; - this.modelVersion = modelVersion; - } - } - - private static ModelIdentity parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { + // modelKey/modelVersion are parsed here alongside the rest of _ldMeta (enabled, variationKey, + // version, mode) rather than inside parseModel, so they're stored the same way as every other + // _ldMeta field instead of a one-off path. They also live under _ldMeta rather than model + // itself, to avoid modelVersion reading as the underlying LLM's own version (e.g. "GPT-5.4"). + private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { if (meta == null || meta.getType() != LDValueType.OBJECT) { - return new ModelIdentity(null, 1); + builder.modelVersion(1); + return; } LDValue enabled = meta.get("enabled"); if (enabled.getType() == LDValueType.BOOLEAN) { @@ -87,9 +77,8 @@ private static ModelIdentity parseMeta(LDValue meta, AIConfigFlagValue.Builder b builder.mode(Mode.fromWireValue(asStringOrNull(meta.get("mode")))); LDValue modelVersion = meta.get("modelVersion"); - int parsedModelVersion = modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1; - String modelKey = trimToNull(asStringOrNull(meta.get("modelKey"))); - return new ModelIdentity(modelKey, parsedModelVersion); + builder.modelVersion(modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1); + builder.modelKey(trimToNull(asStringOrNull(meta.get("modelKey")))); } /** From ac49a9e01dbe3c5ba0d2258abe897b6e7f0745da Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Fri, 17 Jul 2026 12:53:16 -0700 Subject: [PATCH 9/9] fix(ai-configs): stop exposing modelKey/modelVersion on the public Model type modelKey/modelVersion should only be visible via the tracker's stamped event data, mirroring how variationKey/version are internal-only. They now flow from the already-parsed _ldMeta fields straight into the tracker factory instead of being attached to Model. Co-Authored-By: Claude Sonnet 5 --- .../sdk/server/ai/LDAIClientImpl.java | 22 +++---- .../server/ai/datamodel/LDAIConfigTypes.java | 57 +------------------ .../server/ai/internal/AIConfigFlagValue.java | 20 ------- .../server/ai/internal/AIConfigParser.java | 12 +--- .../sdk/server/ai/LDAIClientImplTest.java | 6 +- .../ai/internal/AIConfigParserTest.java | 14 ++--- 6 files changed, 22 insertions(+), 109 deletions(-) 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 71569544..f9e5e41c 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 @@ -265,7 +265,8 @@ private AIConfig buildConfig( String graphKey) { Supplier factory = trackerFactory( key, parsed.getVariationKey(), parsed.getVersion(), - parsed.getModel(), parsed.getProvider(), context, graphKey); + parsed.getModel(), parsed.getProvider(), + parsed.getModelKey(), parsed.getModelVersion(), context, graphKey); switch (mode) { case AGENT: return new AIAgentConfig( @@ -330,7 +331,7 @@ private AIConfig buildConfigFromDefault( 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. - Supplier factory = trackerFactory(key, null, null, null, null, context, graphKey); + Supplier factory = trackerFactory(key, null, null, null, null, null, null, context, graphKey); switch (mode) { case AGENT: { AIAgentConfigDefault agent = (AIAgentConfigDefault) defaultValue; @@ -386,23 +387,14 @@ private Supplier trackerFactory( Integer version, com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model model, com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider provider, - LDContext context) { - return trackerFactory(configKey, variationKey, version, model, provider, context, null); - } - - private Supplier trackerFactory( - String configKey, - String variationKey, - Integer version, - com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model model, - com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider provider, + String modelKey, + Integer modelVersion, LDContext context, String graphKey) { String modelName = model != null && model.getName() != null ? model.getName() : ""; String providerName = provider != null && provider.getName() != null ? provider.getName() : ""; - String modelKey = model != null ? model.getModelKey() : null; - int modelVersion = model != null ? model.getModelVersion() : 1; int ver = version != null ? version : 1; + int mVer = modelVersion != null ? modelVersion : 1; return () -> new LDAIConfigTrackerImpl( client, UUID.randomUUID().toString(), @@ -412,7 +404,7 @@ private Supplier trackerFactory( modelName, providerName, modelKey, - modelVersion, + mVer, context, graphKey, logger); diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java index d2ff9e1c..fb01b7f9 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java @@ -214,20 +214,14 @@ public String toString() { */ public static final class Model { private final String name; - private final String modelKey; - private final int modelVersion; private final Map parameters; private final Map custom; private Model( String name, - String modelKey, - int modelVersion, Map parameters, Map custom) { this.name = name; - this.modelKey = modelKey; - this.modelVersion = modelVersion; this.parameters = parameters; this.custom = custom; } @@ -241,24 +235,6 @@ public String getName() { return name; } - /** - * Returns the stable key of the model configuration. - * - * @return the model key, or {@code null} if none was specified - */ - public String getModelKey() { - return modelKey; - } - - /** - * Returns the version of the model configuration. - * - * @return the model version; defaults to {@code 1} - */ - public int getModelVersion() { - return modelVersion; - } - /** * Returns the model-specific parameters as an unmodifiable map. * @@ -317,21 +293,18 @@ public boolean equals(Object o) { } Model other = (Model) o; return Objects.equals(name, other.name) - && Objects.equals(modelKey, other.modelKey) - && modelVersion == other.modelVersion && parameters.equals(other.parameters) && custom.equals(other.custom); } @Override public int hashCode() { - return Objects.hash(name, modelKey, modelVersion, parameters, custom); + return Objects.hash(name, parameters, custom); } @Override public String toString() { - return "Model{name=" + name + ", modelKey=" + modelKey + ", modelVersion=" + modelVersion - + ", parameters=" + parameters + ", custom=" + custom + '}'; + return "Model{name=" + name + ", parameters=" + parameters + ", custom=" + custom + '}'; } /** @@ -339,8 +312,6 @@ public String toString() { */ public static final class Builder { private final String name; - private String modelKey; - private int modelVersion = 1; private Map parameters; private Map custom; @@ -348,28 +319,6 @@ private Builder(String name) { this.name = name; } - /** - * Sets the stable key of the model configuration. - * - * @param modelKey the model key; may be {@code null} - * @return this builder - */ - public Builder modelKey(String modelKey) { - this.modelKey = modelKey; - return this; - } - - /** - * Sets the version of the model configuration. - * - * @param modelVersion the model version - * @return this builder - */ - public Builder modelVersion(int modelVersion) { - this.modelVersion = modelVersion; - return this; - } - /** * Sets the model-specific parameters. The map is copied defensively. * @@ -404,7 +353,7 @@ public Model build() { Map cust = custom == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(custom)); - return new Model(name, modelKey, modelVersion, params, cust); + return new Model(name, params, cust); } } } diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java index 85a13885..4905032f 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java @@ -269,26 +269,6 @@ public Builder modelVersion(Integer v) { return this; } - /** - * Returns the model key set so far, for {@link AIConfigParser} to pass into - * {@link AIConfigParser#parseModel} alongside the {@code model} value. - * - * @return the model key, or {@code null} if unset - */ - String getModelKey() { - return modelKey; - } - - /** - * Returns the model version set so far, for {@link AIConfigParser} to pass into - * {@link AIConfigParser#parseModel} alongside the {@code model} value. - * - * @return the model version, or {@code null} if unset - */ - Integer getModelVersion() { - return modelVersion; - } - /** * Sets the mode. * diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java index 983f0c66..b5c659f5 100644 --- a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java +++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java @@ -44,8 +44,7 @@ public static AIConfigFlagValue parse(LDValue value) { LDValue meta = value.get("_ldMeta"); parseMeta(meta, builder); - Integer modelVersion = builder.getModelVersion(); - builder.model(parseModel(value.get("model"), builder.getModelKey(), modelVersion == null ? 1 : modelVersion)); + builder.model(parseModel(value.get("model"))); builder.provider(parseProvider(value.get("provider"))); builder.messages(parseMessages(value.get("messages"))); builder.instructions(asStringOrNull(value.get("instructions"))); @@ -82,21 +81,16 @@ private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) { } /** - * Parses a {@code model} object, attaching the {@code modelKey}/{@code modelVersion} already - * parsed from {@code _ldMeta} by {@link #parseMeta}. + * Parses a {@code model} object. * * @param model the model value - * @param modelKey the model key parsed from {@code _ldMeta}, or {@code null} - * @param modelVersion the model version parsed from {@code _ldMeta}, defaulting to 1 * @return a {@link Model}, or {@code null} if the value is not an object */ - static Model parseModel(LDValue model, String modelKey, int modelVersion) { + static Model parseModel(LDValue model) { if (model == null || model.getType() != LDValueType.OBJECT) { return null; } return Model.builder(asStringOrNull(model.get("name"))) - .modelKey(modelKey) - .modelVersion(modelVersion) .parameters(LDValueConverter.toMap(model.get("parameters"))) .custom(LDValueConverter.toMap(model.get("custom"))) .build(); 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 40046711..7075383e 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 @@ -143,8 +143,8 @@ public void completionConfigPropagatesModelMetadataToTracker() { AICompletionConfig config = ai.completionConfig("key", context, null, null); - assertThat(config.getModel().getModelKey(), is("custom-gpt")); - assertThat(config.getModel().getModelVersion(), is(7)); + // modelKey/modelVersion are intentionally not exposed on Model; they surface only via the + // tracker's stamped event data, mirroring variationKey/version. assertThat(config.createTracker().getTrackData().getModelKey(), is("custom-gpt")); assertThat(config.createTracker().getTrackData().getModelVersion(), is(7)); } @@ -157,8 +157,6 @@ public void completionConfigDefaultsMissingModelMetadata() { AICompletionConfig config = ai.completionConfig("key", context, null, null); - assertThat(config.getModel().getModelKey(), is(nullValue())); - assertThat(config.getModel().getModelVersion(), is(1)); assertThat(config.createTracker().getTrackData().getModelKey(), is(nullValue())); assertThat(config.createTracker().getTrackData().getModelVersion(), is(1)); } diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java index 117f0bd7..35baa149 100644 --- a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java +++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParserTest.java @@ -41,8 +41,8 @@ public void parsesFullCompletionConfig() { assertThat(parsed.getVersion(), is(3)); assertThat(parsed.getMode(), is(Mode.COMPLETION)); assertThat(parsed.getModel().getName(), is("gpt-4")); - assertThat(parsed.getModel().getModelKey(), is("custom-gpt")); - assertThat(parsed.getModel().getModelVersion(), is(7)); + assertThat(parsed.getModelKey(), is("custom-gpt")); + assertThat(parsed.getModelVersion(), is(7)); assertThat(parsed.getModel().getParameter("temperature"), is((Object) 0.7)); assertThat(parsed.getModel().getParameter("maxTokens"), is((Object) 100L)); assertThat(parsed.getModel().getCustom("team"), is((Object) "core")); @@ -61,17 +61,17 @@ public void parsesFullCompletionConfig() { public void modelMetadataDefaultsWhenMissingOrWrongType() { AIConfigFlagValue missing = AIConfigParser.parse( LDValue.parse("{\"model\":{\"name\":\"gpt-4\"}}")); - assertThat(missing.getModel().getModelKey(), is(nullValue())); - assertThat(missing.getModel().getModelVersion(), is(1)); + assertThat(missing.getModelKey(), is(nullValue())); + assertThat(missing.getModelVersion(), is(1)); AIConfigFlagValue wrongType = AIConfigParser.parse( LDValue.parse("{\"_ldMeta\":{\"modelKey\":3,\"modelVersion\":\"two\"},\"model\":{}}")); - assertThat(wrongType.getModel().getModelKey(), is(nullValue())); - assertThat(wrongType.getModel().getModelVersion(), is(1)); + assertThat(wrongType.getModelKey(), is(nullValue())); + assertThat(wrongType.getModelVersion(), is(1)); AIConfigFlagValue blankKey = AIConfigParser.parse( LDValue.parse("{\"_ldMeta\":{\"modelKey\":\" \"},\"model\":{}}")); - assertThat(blankKey.getModel().getModelKey(), is(nullValue())); + assertThat(blankKey.getModelKey(), is(nullValue())); } @Test