Skip to content

Commit 58f3c25

Browse files
atornsiicursoragentclaude
authored
feat(server-ai): stamp model metadata on AI usage events (#192)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> **Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's pull request submission guidelines - [ ] I have validated my changes against all supported platform versions **Related issues** - [AIC-2977](https://launchdarkly.atlassian.net/browse/AIC-2977) - Parent: [AIC-2849](https://launchdarkly.atlassian.net/browse/AIC-2849) **Describe the solution you've provided** Read `modelKey` and `modelVersion` from AI Config model payloads, expose them on the public model/tracking data types, and stamp them on every emitted tracker metric event. Model version defaults to `1`, null/empty/whitespace model keys are omitted, and the resumption-token format remains unchanged. **Describe alternatives you've considered** This mirrors the established JavaScript and .NET SDK implementations. **Additional context** Depends on AIC-2876 for the backend payload fields. ## Test plan - [x] `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 lib/sdk/server-ai/gradlew test -p lib/sdk/server-ai` - [x] `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 lib/sdk/server-ai/gradlew build -p lib/sdk/server-ai` - [x] `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 lib/sdk/server-ai/gradlew javadoc -p lib/sdk/server-ai` - [ ] CI Java 8/11/17 matrix <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-dbeb9c1a-5b43-487b-824d-c0a2ab5f9a0e"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-dbeb9c1a-5b43-487b-824d-c0a2ab5f9a0e"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> [AIC-2977]: https://launchdarkly.atlassian.net/browse/AIC-2977?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [AIC-2849]: https://launchdarkly.atlassian.net/browse/AIC-2849?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Anthony Torns II <atornsii@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0bcfbad commit 58f3c25

9 files changed

Lines changed: 241 additions & 24 deletions

File tree

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,8 @@ private AIConfig buildConfig(
265265
String graphKey) {
266266
Supplier<LDAIConfigTracker> factory = trackerFactory(
267267
key, parsed.getVariationKey(), parsed.getVersion(),
268-
parsed.getModel(), parsed.getProvider(), context, graphKey);
268+
parsed.getModel(), parsed.getProvider(),
269+
parsed.getModelKey(), parsed.getModelVersion(), context, graphKey);
269270
switch (mode) {
270271
case AGENT:
271272
return new AIAgentConfig(
@@ -330,7 +331,7 @@ private AIConfig buildConfigFromDefault(
330331
String graphKey) {
331332
// Default configs still get real trackers — the configKey was requested even if no flag was found.
332333
// variationKey is null because no flag evaluation occurred.
333-
Supplier<LDAIConfigTracker> factory = trackerFactory(key, null, null, null, null, context, graphKey);
334+
Supplier<LDAIConfigTracker> factory = trackerFactory(key, null, null, null, null, null, null, context, graphKey);
334335
switch (mode) {
335336
case AGENT: {
336337
AIAgentConfigDefault agent = (AIAgentConfigDefault) defaultValue;
@@ -386,21 +387,14 @@ private Supplier<LDAIConfigTracker> trackerFactory(
386387
Integer version,
387388
com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model model,
388389
com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider provider,
389-
LDContext context) {
390-
return trackerFactory(configKey, variationKey, version, model, provider, context, null);
391-
}
392-
393-
private Supplier<LDAIConfigTracker> trackerFactory(
394-
String configKey,
395-
String variationKey,
396-
Integer version,
397-
com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Model model,
398-
com.launchdarkly.sdk.server.ai.datamodel.LDAIConfigTypes.Provider provider,
390+
String modelKey,
391+
Integer modelVersion,
399392
LDContext context,
400393
String graphKey) {
401394
String modelName = model != null && model.getName() != null ? model.getName() : "";
402395
String providerName = provider != null && provider.getName() != null ? provider.getName() : "";
403396
int ver = version != null ? version : 1;
397+
int mVer = modelVersion != null ? modelVersion : 1;
404398
return () -> new LDAIConfigTrackerImpl(
405399
client,
406400
UUID.randomUUID().toString(),
@@ -409,6 +403,8 @@ private Supplier<LDAIConfigTracker> trackerFactory(
409403
ver,
410404
modelName,
411405
providerName,
406+
modelKey,
407+
mVer,
412408
context,
413409
graphKey,
414410
logger);

‎lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAIConfigTypes.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ public static final class Model {
217217
private final Map<String, Object> parameters;
218218
private final Map<String, Object> custom;
219219

220-
private Model(String name, Map<String, Object> parameters, Map<String, Object> custom) {
220+
private Model(
221+
String name,
222+
Map<String, Object> parameters,
223+
Map<String, Object> custom) {
221224
this.name = name;
222225
this.parameters = parameters;
223226
this.custom = custom;

‎lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/datamodel/LDAITrackingTypes.java‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,8 @@ public static final class TrackData {
599599
private final int version;
600600
private final String modelName;
601601
private final String providerName;
602+
private final String modelKey;
603+
private final int modelVersion;
602604
private final String graphKey;
603605

604606
/**
@@ -610,6 +612,8 @@ public static final class TrackData {
610612
* @param version the config version
611613
* @param modelName the model name, or empty string when unknown
612614
* @param providerName the provider name, or empty string when unknown
615+
* @param modelKey the stable model key, or {@code null} when unknown
616+
* @param modelVersion the model version
613617
* @param graphKey the agent graph key, or {@code null} when not part of a graph
614618
*/
615619
public TrackData(
@@ -619,13 +623,17 @@ public TrackData(
619623
int version,
620624
String modelName,
621625
String providerName,
626+
String modelKey,
627+
int modelVersion,
622628
String graphKey) {
623629
this.runId = Objects.requireNonNull(runId, "runId");
624630
this.configKey = Objects.requireNonNull(configKey, "configKey");
625631
this.variationKey = variationKey;
626632
this.version = version;
627633
this.modelName = modelName == null ? "" : modelName;
628634
this.providerName = providerName == null ? "" : providerName;
635+
this.modelKey = modelKey == null || modelKey.trim().isEmpty() ? null : modelKey;
636+
this.modelVersion = modelVersion;
629637
this.graphKey = graphKey;
630638
}
631639

@@ -683,6 +691,24 @@ public String getProviderName() {
683691
return providerName;
684692
}
685693

694+
/**
695+
* Returns the stable model key.
696+
*
697+
* @return the model key, or {@code null} when unknown
698+
*/
699+
public String getModelKey() {
700+
return modelKey;
701+
}
702+
703+
/**
704+
* Returns the model version.
705+
*
706+
* @return the model version
707+
*/
708+
public int getModelVersion() {
709+
return modelVersion;
710+
}
711+
686712
/**
687713
* Returns the agent graph key.
688714
*
@@ -695,7 +721,7 @@ public String getGraphKey() {
695721
/**
696722
* Builds an {@link LDValue} representation of this track data using camelCase keys.
697723
* <p>
698-
* {@code variationKey} and {@code graphKey} are omitted when {@code null}.
724+
* {@code variationKey}, {@code modelKey}, and {@code graphKey} are omitted when {@code null}.
699725
*
700726
* @return an {@link LDValue} object containing all non-null fields
701727
*/
@@ -705,10 +731,14 @@ public LDValue toLDValue() {
705731
.put("configKey", configKey)
706732
.put("version", version)
707733
.put("modelName", modelName)
708-
.put("providerName", providerName);
734+
.put("providerName", providerName)
735+
.put("modelVersion", modelVersion);
709736
if (variationKey != null) {
710737
b.put("variationKey", variationKey);
711738
}
739+
if (modelKey != null) {
740+
b.put("modelKey", modelKey);
741+
}
712742
if (graphKey != null) {
713743
b.put("graphKey", graphKey);
714744
}

‎lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigFlagValue.java‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ public final class AIConfigFlagValue {
3737
private final Map<String, Tool> tools;
3838
private final JudgeConfiguration judgeConfiguration;
3939
private final String evaluationMetricKey;
40+
private final String modelKey;
41+
private final Integer modelVersion;
4042

4143
private AIConfigFlagValue(Builder b) {
4244
this.enabled = b.enabled;
@@ -50,6 +52,8 @@ private AIConfigFlagValue(Builder b) {
5052
this.tools = b.tools == null ? null : Collections.unmodifiableMap(b.tools);
5153
this.judgeConfiguration = b.judgeConfiguration;
5254
this.evaluationMetricKey = b.evaluationMetricKey;
55+
this.modelKey = b.modelKey;
56+
this.modelVersion = b.modelVersion;
5357
}
5458

5559
/**
@@ -97,6 +101,26 @@ public Mode getMode() {
97101
return mode;
98102
}
99103

104+
/**
105+
* Returns the {@code _ldMeta.modelKey}. Lives under {@code _ldMeta} rather than {@code model}
106+
* itself, to avoid {@code modelVersion} reading as the underlying LLM's own version
107+
* (e.g. "GPT-5.4").
108+
*
109+
* @return the model key, or {@code null} if absent
110+
*/
111+
public String getModelKey() {
112+
return modelKey;
113+
}
114+
115+
/**
116+
* Returns the {@code _ldMeta.modelVersion}.
117+
*
118+
* @return the model version, or {@code null} if absent
119+
*/
120+
public Integer getModelVersion() {
121+
return modelVersion;
122+
}
123+
100124
/**
101125
* Returns the model configuration.
102126
*
@@ -184,6 +208,8 @@ public static final class Builder {
184208
private Map<String, Tool> tools;
185209
private JudgeConfiguration judgeConfiguration;
186210
private String evaluationMetricKey;
211+
private String modelKey;
212+
private Integer modelVersion;
187213

188214
private Builder() {
189215
}
@@ -221,6 +247,28 @@ public Builder version(Integer v) {
221247
return this;
222248
}
223249

250+
/**
251+
* Sets the model key, parsed from {@code _ldMeta.modelKey}.
252+
*
253+
* @param v the model key
254+
* @return this builder
255+
*/
256+
public Builder modelKey(String v) {
257+
this.modelKey = v;
258+
return this;
259+
}
260+
261+
/**
262+
* Sets the model version, parsed from {@code _ldMeta.modelVersion}.
263+
*
264+
* @param v the model version
265+
* @return this builder
266+
*/
267+
public Builder modelVersion(Integer v) {
268+
this.modelVersion = v;
269+
return this;
270+
}
271+
224272
/**
225273
* Sets the mode.
226274
*

‎lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/AIConfigParser.java‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ public static AIConfigFlagValue parse(LDValue value) {
4242
return builder.build();
4343
}
4444

45-
parseMeta(value.get("_ldMeta"), builder);
45+
LDValue meta = value.get("_ldMeta");
46+
parseMeta(meta, builder);
4647
builder.model(parseModel(value.get("model")));
4748
builder.provider(parseProvider(value.get("provider")));
4849
builder.messages(parseMessages(value.get("messages")));
@@ -54,8 +55,13 @@ public static AIConfigFlagValue parse(LDValue value) {
5455
return builder.build();
5556
}
5657

58+
// modelKey/modelVersion are parsed here alongside the rest of _ldMeta (enabled, variationKey,
59+
// version, mode) rather than inside parseModel, so they're stored the same way as every other
60+
// _ldMeta field instead of a one-off path. They also live under _ldMeta rather than model
61+
// itself, to avoid modelVersion reading as the underlying LLM's own version (e.g. "GPT-5.4").
5762
private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) {
5863
if (meta == null || meta.getType() != LDValueType.OBJECT) {
64+
builder.modelVersion(1);
5965
return;
6066
}
6167
LDValue enabled = meta.get("enabled");
@@ -68,6 +74,10 @@ private static void parseMeta(LDValue meta, AIConfigFlagValue.Builder builder) {
6874
builder.version(version.intValue());
6975
}
7076
builder.mode(Mode.fromWireValue(asStringOrNull(meta.get("mode"))));
77+
78+
LDValue modelVersion = meta.get("modelVersion");
79+
builder.modelVersion(modelVersion.getType() == LDValueType.NUMBER ? modelVersion.intValue() : 1);
80+
builder.modelKey(trimToNull(asStringOrNull(meta.get("modelKey"))));
7181
}
7282

7383
/**

‎lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/LDAIConfigTrackerImpl.java‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public final class LDAIConfigTrackerImpl implements LDAIConfigTracker {
5858
private final int version;
5959
private final String modelName; // empty string when unknown
6060
private final String providerName; // empty string when unknown
61+
private final String modelKey; // nullable
62+
private final int modelVersion;
6163
private final String graphKey; // nullable
6264

6365
// Computed once at construction
@@ -86,6 +88,8 @@ public final class LDAIConfigTrackerImpl implements LDAIConfigTracker {
8688
* @param version the config version
8789
* @param modelName the model name, or empty string when unknown
8890
* @param providerName the provider name, or empty string when unknown
91+
* @param modelKey the stable model key, or {@code null} when unknown
92+
* @param modelVersion the model version
8993
* @param context the evaluation context; must not be {@code null}
9094
* @param graphKey the agent graph key, or {@code null} when not part of a graph
9195
* @param logger the logger; must not be {@code null}
@@ -98,6 +102,8 @@ public LDAIConfigTrackerImpl(
98102
int version,
99103
String modelName,
100104
String providerName,
105+
String modelKey,
106+
int modelVersion,
101107
LDContext context,
102108
String graphKey,
103109
LDLogger logger) {
@@ -108,6 +114,8 @@ public LDAIConfigTrackerImpl(
108114
this.version = version;
109115
this.modelName = modelName == null ? "" : modelName;
110116
this.providerName = providerName == null ? "" : providerName;
117+
this.modelKey = modelKey == null || modelKey.trim().isEmpty() ? null : modelKey;
118+
this.modelVersion = modelVersion;
111119
this.context = Objects.requireNonNull(context, "context");
112120
this.graphKey = graphKey;
113121
this.logger = Objects.requireNonNull(logger, "logger");
@@ -137,14 +145,25 @@ public static LDAIConfigTrackerImpl fromResumptionToken(
137145
d.getVersion(),
138146
"", // modelName not carried in token
139147
"", // providerName not carried in token
148+
null, // modelKey not carried in token
149+
1, // modelVersion not carried in token
140150
context,
141151
d.getGraphKey(),
142152
logger);
143153
}
144154

145155
@Override
146156
public TrackData getTrackData() {
147-
return new TrackData(runId, configKey, variationKey, version, modelName, providerName, graphKey);
157+
return new TrackData(
158+
runId,
159+
configKey,
160+
variationKey,
161+
version,
162+
modelName,
163+
providerName,
164+
modelKey,
165+
modelVersion,
166+
graphKey);
148167
}
149168

150169
@Override
@@ -377,10 +396,14 @@ private ObjectBuilder baseData() {
377396
.put("configKey", configKey)
378397
.put("version", version)
379398
.put("modelName", modelName)
380-
.put("providerName", providerName);
399+
.put("providerName", providerName)
400+
.put("modelVersion", modelVersion);
381401
if (variationKey != null) {
382402
b.put("variationKey", variationKey);
383403
}
404+
if (modelKey != null) {
405+
b.put("modelKey", modelKey);
406+
}
384407
if (graphKey != null) {
385408
b.put("graphKey", graphKey);
386409
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,33 @@ public void completionConfigReturnsTypedConfigFromVariation() {
134134
assertThat(config.getMessages().get(0).getRole(), is(Message.Role.SYSTEM));
135135
}
136136

137+
@Test
138+
public void completionConfigPropagatesModelMetadataToTracker() {
139+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\","
140+
+ "\"modelKey\":\"custom-gpt\",\"modelVersion\":7},"
141+
+ "\"model\":{\"name\":\"gpt-4\"}}";
142+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
143+
144+
AICompletionConfig config = ai.completionConfig("key", context, null, null);
145+
146+
// modelKey/modelVersion are intentionally not exposed on Model; they surface only via the
147+
// tracker's stamped event data, mirroring variationKey/version.
148+
assertThat(config.createTracker().getTrackData().getModelKey(), is("custom-gpt"));
149+
assertThat(config.createTracker().getTrackData().getModelVersion(), is(7));
150+
}
151+
152+
@Test
153+
public void completionConfigDefaultsMissingModelMetadata() {
154+
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},"
155+
+ "\"model\":{\"name\":\"gpt-4\"}}";
156+
when(client.jsonValueVariation(anyString(), any(), any())).thenReturn(LDValue.parse(json));
157+
158+
AICompletionConfig config = ai.completionConfig("key", context, null, null);
159+
160+
assertThat(config.createTracker().getTrackData().getModelKey(), is(nullValue()));
161+
assertThat(config.createTracker().getTrackData().getModelVersion(), is(1));
162+
}
163+
137164
@Test
138165
public void interpolationExposesContextAsLdctx() {
139166
String json = "{\"_ldMeta\":{\"enabled\":true,\"mode\":\"completion\"},"

0 commit comments

Comments
 (0)