From 0bea1073ad3549345ad87647f9a6499a841897cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:24:19 +0000 Subject: [PATCH 1/4] feat(server-ai): stamp modelKey and modelVersion on AI usage events (AIC-2856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read modelKey and modelVersion from the AI Config variation payload and stamp them on all LdAiConfigTracker metric event payloads alongside existing modelName/providerName fields. - Add ModelKey/ModelVersion to ModelConfig - Parse new fields from variation payload in ConfigFactory - Include modelVersion unconditionally (default 1) and modelKey when non-empty in track data; exclude both from resumption token - Additive/backward compatible — older payloads without the new fields continue to work Part of AIC-2849. Depends on AIC-2876 (backend payload). Co-authored-by: Anthony Torns II --- .../sdk/server-ai/src/Config/ConfigFactory.cs | 7 +- .../server-ai/src/Config/LdAiConfigTypes.cs | 20 ++- pkgs/sdk/server-ai/src/LdAiConfigTracker.cs | 13 +- .../server-ai/test/ConfigFactoryParserTest.cs | 114 ++++++++++++++++++ .../test/LdAiAgentGraphConfigTest.cs | 2 +- pkgs/sdk/server-ai/test/LdAiClientTest.cs | 4 +- .../server-ai/test/LdAiConfigTrackerTest.cs | 78 ++++++++++++ 7 files changed, 231 insertions(+), 7 deletions(-) diff --git a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs index e6eee49d..c2aff1cf 100644 --- a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs +++ b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs @@ -305,6 +305,8 @@ private Func TrackerFactoryFor(Context context, context, cfg.Model?.Name, cfg.Provider?.Name, + cfg.Model?.ModelKey, + cfg.Model?.ModelVersion ?? 1, graphKey); } @@ -326,7 +328,10 @@ private static LdAiConfigTypes.ModelConfig ParseModel(LdValue modelValue) var name = modelValue.Get("name").AsString ?? ""; var parameters = LdValueObjectToDictionary(modelValue.Get("parameters")); var custom = LdValueObjectToDictionary(modelValue.Get("custom")); - return new LdAiConfigTypes.ModelConfig(name, parameters, custom); + var modelKey = modelValue.Get("modelKey").AsString; + var modelVersionValue = modelValue.Get("modelVersion"); + var modelVersion = modelVersionValue.IsNull ? 1 : modelVersionValue.AsInt; + return new LdAiConfigTypes.ModelConfig(name, parameters, custom, modelKey, modelVersion); } private static LdAiConfigTypes.ProviderConfig ParseProvider(LdValue providerValue) diff --git a/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs b/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs index a4278ab7..25115bba 100644 --- a/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs +++ b/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs @@ -71,7 +71,23 @@ public sealed record ModelConfig /// public readonly IReadOnlyDictionary Custom; - internal ModelConfig(string name, IReadOnlyDictionary parameters, IReadOnlyDictionary custom) + /// + /// The stable, unique key of the model (used for direct lookup; distinct from + /// , which is not guaranteed unique). + /// + public readonly string ModelKey; + + /// + /// The pinned version of the model that this config variation references. + /// + public readonly int ModelVersion; + + internal ModelConfig( + string name, + IReadOnlyDictionary parameters, + IReadOnlyDictionary custom, + string modelKey = null, + int modelVersion = 1) { Name = name; // Materialize into an ImmutableDictionary so a consumer that downcasts to @@ -80,6 +96,8 @@ internal ModelConfig(string name, IReadOnlyDictionary parameter // NotSupportedException at runtime, matching the typed read-only promise. Parameters = parameters?.ToImmutableDictionary() ?? ImmutableDictionary.Empty; Custom = custom?.ToImmutableDictionary() ?? ImmutableDictionary.Empty; + ModelKey = modelKey; + ModelVersion = modelVersion; } } diff --git a/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs b/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs index 70a2182e..8ad6721e 100644 --- a/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs +++ b/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs @@ -76,7 +76,7 @@ public class LdAiConfigTracker : ILdAiConfigTracker /// internal LdAiConfigTracker(ILaunchDarklyClient client, string runId, string configKey, string variationKey, int version, Context context, string modelName, string providerName, - string graphKey = null) + string modelKey = null, int modelVersion = 1, string graphKey = null) { _client = client ?? throw new ArgumentNullException(nameof(client)); _configKey = configKey ?? throw new ArgumentNullException(nameof(configKey)); @@ -96,6 +96,7 @@ internal LdAiConfigTracker(ILaunchDarklyClient client, string runId, string conf { "version", LdValue.Of(_version) }, { "modelName", LdValue.Of(_modelName) }, { "providerName", LdValue.Of(_providerName) }, + { "modelVersion", LdValue.Of(modelVersion) }, }; if (!string.IsNullOrEmpty(_graphKey)) { @@ -105,6 +106,10 @@ internal LdAiConfigTracker(ILaunchDarklyClient client, string runId, string conf { trackDataBuilder.Add("variationKey", LdValue.Of(_variationKey)); } + if (!string.IsNullOrEmpty(modelKey)) + { + trackDataBuilder.Add("modelKey", LdValue.Of(modelKey)); + } _trackData = LdValue.ObjectFrom(trackDataBuilder); _resumptionToken = new Lazy(BuildResumptionToken); @@ -118,6 +123,7 @@ private string BuildResumptionToken() // Utf8JsonWriter gives stable key ordering and avoids the runtime cost of // anonymous-type reflection. The wire format omits empty optional fields so that // resumption tokens round-trip exactly for configs that never carried them. + // modelName, providerName, modelKey, and modelVersion are not included. using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { @@ -426,7 +432,8 @@ private LdValue MergeTrackData(string key, LdValue value) /// process or at a later time. /// /// The reconstructed tracker will have empty model and provider names, as these are not - /// included in the resumption token. + /// included in the resumption token. modelKey and modelVersion are also not included; + /// modelVersion defaults to 1 on reconstructed trackers. /// /// the resumption token obtained from /// the LaunchDarkly client @@ -466,7 +473,7 @@ public static LdAiConfigTracker FromResumptionToken(string token, ILaunchDarklyC } return new LdAiConfigTracker(client, payload.RunId, payload.ConfigKey, - payload.VariationKey, payload.Version ?? 1, context, "", "", payload.GraphKey); + payload.VariationKey, payload.Version ?? 1, context, "", "", graphKey: payload.GraphKey); } private class ResumptionPayload diff --git a/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs b/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs index 004cebce..ec04ee15 100644 --- a/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs +++ b/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using LaunchDarkly.Sdk.Server.Ai.Config; +using LaunchDarkly.Sdk.Server.Ai.Interfaces; +using Moq; using Xunit; namespace LaunchDarkly.Sdk.Server.Ai; @@ -204,4 +206,116 @@ public void ParseAllFields_WithNoNewFields_ReturnsNullOrEmptyWithoutError() Assert.Null(judgeConfig); Assert.Null(evaluationMetricKey); } + + [Fact] + public void CompletionConfigReadsModelKeyAndVersionFromFlag() + { + var mockClient = new Mock(); + var mockLogger = new Mock(); + mockClient.Setup(x => x.GetLogger()).Returns(mockLogger.Object); + mockClient.Setup(x => + x.JsonVariation("model-config-with-key-version", It.IsAny(), It.IsAny())) + .Returns(LdValue.ObjectFrom(new Dictionary + { + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(true), + ["variationKey"] = LdValue.Of("v1"), + ["version"] = LdValue.Of(1) + }), + ["model"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("gpt-4"), + ["modelKey"] = LdValue.Of("my-model"), + ["modelVersion"] = LdValue.Of(2) + }), + ["provider"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("openai") + }), + ["messages"] = LdValue.ArrayOf() + })); + + var client = new LdAiClient(mockClient.Object); + var result = client.CompletionConfig("model-config-with-key-version", Context.New("user-key")); + + Assert.Equal("my-model", result.Model.ModelKey); + Assert.Equal(2, result.Model.ModelVersion); + } + + [Fact] + public void CompletionConfigDefaultsModelVersionWhenAbsent() + { + var mockClient = new Mock(); + var mockLogger = new Mock(); + mockClient.Setup(x => x.GetLogger()).Returns(mockLogger.Object); + mockClient.Setup(x => + x.JsonVariation("model-config-no-version", It.IsAny(), It.IsAny())) + .Returns(LdValue.ObjectFrom(new Dictionary + { + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(true), + ["variationKey"] = LdValue.Of("v1"), + ["version"] = LdValue.Of(1) + }), + ["model"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("gpt-4") + }), + ["provider"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("openai") + }), + ["messages"] = LdValue.ArrayOf() + })); + + var client = new LdAiClient(mockClient.Object); + var result = client.CompletionConfig("model-config-no-version", Context.New("user-key")); + + Assert.Null(result.Model.ModelKey); + Assert.Equal(1, result.Model.ModelVersion); + } + + [Fact] + public void CreateTrackerStampsModelKeyAndVersionOnTrackData() + { + var mockClient = new Mock(); + var mockLogger = new Mock(); + mockClient.Setup(x => x.GetLogger()).Returns(mockLogger.Object); + mockClient.Setup(x => + x.JsonVariation("my-config-key", It.IsAny(), It.IsAny())) + .Returns(LdValue.ObjectFrom(new Dictionary + { + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(true), + ["variationKey"] = LdValue.Of("var-abc"), + ["version"] = LdValue.Of(7) + }), + ["model"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("gpt-4"), + ["modelKey"] = LdValue.Of("my-model"), + ["modelVersion"] = LdValue.Of(2) + }), + ["provider"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("openai") + }), + ["messages"] = LdValue.ArrayOf() + })); + + var client = new LdAiClient(mockClient.Object); + var context = Context.New("user-key"); + var config = client.CompletionConfig("my-config-key", context); + var tracker = config.CreateTracker(); + tracker.TrackSuccess(); + + mockClient.Verify(x => x.Track("$ld:ai:generation:success", context, + It.Is(d => + d.Get("modelKey").AsString == "my-model" && + d.Get("modelVersion").AsInt == 2), + 1.0f), Times.Once); + } } diff --git a/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs b/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs index 7e47c846..b5ba1550 100644 --- a/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs +++ b/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs @@ -24,7 +24,7 @@ private static LdAiConfigTracker MakeTracker(ILaunchDarklyClient client, Context string graphKey = null) { return new LdAiConfigTracker(client, Guid.NewGuid().ToString(), "config-key", - "v1", 1, context, "model", "provider", graphKey); + "v1", 1, context, "model", "provider", graphKey: graphKey); } // Test 1: Tracker created with graphKey → events include graphKey in data diff --git a/pkgs/sdk/server-ai/test/LdAiClientTest.cs b/pkgs/sdk/server-ai/test/LdAiClientTest.cs index dc4034f1..7f133f27 100644 --- a/pkgs/sdk/server-ai/test/LdAiClientTest.cs +++ b/pkgs/sdk/server-ai/test/LdAiClientTest.cs @@ -668,7 +668,9 @@ public void CreateTrackerFromResumptionTokenSetsEmptyModelAndProvider() d.Get("variationKey").AsString == "var-1" && d.Get("version").AsInt == 2 && d.Get("modelName").AsString == "" && - d.Get("providerName").AsString == ""), + d.Get("providerName").AsString == "" && + d.Get("modelVersion").AsInt == 1 && + d.Get("modelKey").IsNull), 1.0f), Times.Once); } diff --git a/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs b/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs index 0bd7793a..f701ad25 100644 --- a/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs +++ b/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs @@ -686,6 +686,84 @@ public void ResumptionTokenContainsExpectedFields() // modelName and providerName should NOT be in the token Assert.False(doc.RootElement.TryGetProperty("modelName", out _)); Assert.False(doc.RootElement.TryGetProperty("providerName", out _)); + Assert.False(doc.RootElement.TryGetProperty("modelKey", out _)); + Assert.False(doc.RootElement.TryGetProperty("modelVersion", out _)); + } + + [Fact] + public void TrackDataIncludesModelVersionByDefault() + { + var mockClient = new Mock(); + var context = Context.New("key"); + + var tracker = new LdAiConfigTracker(mockClient.Object, "test-run-id", + "config-key", "variation-key", 3, context, "fakeModel", "fakeProvider"); + tracker.TrackSuccess(); + + mockClient.Verify(x => x.Track("$ld:ai:generation:success", context, + It.Is(d => d.Get("modelVersion").AsInt == 1), 1.0f), Times.Once); + } + + [Fact] + public void TrackDataIncludesModelKeyWhenSet() + { + var mockClient = new Mock(); + var context = Context.New("key"); + + var tracker = new LdAiConfigTracker(mockClient.Object, "test-run-id", + "config-key", "variation-key", 3, context, "fakeModel", "fakeProvider", + modelKey: "my-model", modelVersion: 2); + tracker.TrackSuccess(); + + mockClient.Verify(x => x.Track("$ld:ai:generation:success", context, + It.Is(d => + d.Get("modelKey").AsString == "my-model" && + d.Get("modelVersion").AsInt == 2), + 1.0f), Times.Once); + } + + [Fact] + public void TrackDataOmitsModelKeyWhenEmpty() + { + var mockClient = new Mock(); + var context = Context.New("key"); + + var tracker = new LdAiConfigTracker(mockClient.Object, "test-run-id", + "config-key", "variation-key", 3, context, "fakeModel", "fakeProvider", + modelKey: "", modelVersion: 3); + tracker.TrackSuccess(); + + mockClient.Verify(x => x.Track("$ld:ai:generation:success", context, + It.Is(d => + d.Get("modelVersion").AsInt == 3 && + d.Get("modelKey").IsNull), + 1.0f), Times.Once); + } + + [Fact] + public void FromResumptionTokenDefaultsModelVersionAndOmitsModelKey() + { + var mockClient = new Mock(); + var context = Context.New("key"); + + var payload = JsonSerializer.Serialize(new + { + runId = "test-run-id", + configKey = "test-key", + variationKey = "var-1", + version = 2, + }); + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); + var token = base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); + + var tracker = LdAiConfigTracker.FromResumptionToken(token, mockClient.Object, context); + tracker.TrackSuccess(); + + mockClient.Verify(x => x.Track("$ld:ai:generation:success", context, + It.Is(d => + d.Get("modelVersion").AsInt == 1 && + d.Get("modelKey").IsNull), + 1.0f), Times.Once); } [Fact] From c7dfc7d6ab20d3cf35b7746ca4aa7ec4ce6228cd Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Wed, 15 Jul 2026 16:32:45 -0700 Subject: [PATCH 2/4] 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. ModelConfig's public properties and the tracker's stamped event fields are untouched. Co-Authored-By: Claude Fable 5 --- pkgs/sdk/server-ai/src/Config/ConfigFactory.cs | 14 ++++++++------ .../server-ai/test/ConfigFactoryParserTest.cs | 16 ++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs index c2aff1cf..8073c59a 100644 --- a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs +++ b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs @@ -57,7 +57,7 @@ public LdAiCompletionConfig BuildCompletionConfig( return BuildCompletionFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue.Get("model")); + var model = ParseModel(ldValue); var provider = ParseProvider(ldValue.Get("provider")); var messages = interpolate ? InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key) @@ -133,7 +133,7 @@ public LdAiAgentConfig BuildAgentConfig( return BuildAgentFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue.Get("model")); + var model = ParseModel(ldValue); var provider = ParseProvider(ldValue.Get("provider")); var tools = ParseTools(ldValue.Get("tools")); var instructions = interpolate @@ -206,7 +206,7 @@ public LdAiJudgeConfig BuildJudgeConfig( return BuildJudgeFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue.Get("model")); + var model = ParseModel(ldValue); var provider = ParseProvider(ldValue.Get("provider")); var messages = interpolate ? InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key) @@ -323,13 +323,15 @@ private static (bool Enabled, string VariationKey, int Version, string Mode) Par return (enabled, variationKey, version, mode); } - private static LdAiConfigTypes.ModelConfig ParseModel(LdValue modelValue) + private static LdAiConfigTypes.ModelConfig ParseModel(LdValue value) { + var modelValue = value.Get("model"); var name = modelValue.Get("name").AsString ?? ""; var parameters = LdValueObjectToDictionary(modelValue.Get("parameters")); var custom = LdValueObjectToDictionary(modelValue.Get("custom")); - var modelKey = modelValue.Get("modelKey").AsString; - var modelVersionValue = modelValue.Get("modelVersion"); + var meta = value.Get("_ldMeta"); + var modelKey = meta.Get("modelKey").AsString; + var modelVersionValue = meta.Get("modelVersion"); var modelVersion = modelVersionValue.IsNull ? 1 : modelVersionValue.AsInt; return new LdAiConfigTypes.ModelConfig(name, parameters, custom, modelKey, modelVersion); } diff --git a/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs b/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs index ec04ee15..a2fb924e 100644 --- a/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs +++ b/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs @@ -221,13 +221,13 @@ public void CompletionConfigReadsModelKeyAndVersionFromFlag() { ["enabled"] = LdValue.Of(true), ["variationKey"] = LdValue.Of("v1"), - ["version"] = LdValue.Of(1) + ["version"] = LdValue.Of(1), + ["modelKey"] = LdValue.Of("my-model"), + ["modelVersion"] = LdValue.Of(2) }), ["model"] = LdValue.ObjectFrom(new Dictionary { - ["name"] = LdValue.Of("gpt-4"), - ["modelKey"] = LdValue.Of("my-model"), - ["modelVersion"] = LdValue.Of(2) + ["name"] = LdValue.Of("gpt-4") }), ["provider"] = LdValue.ObjectFrom(new Dictionary { @@ -291,13 +291,13 @@ public void CreateTrackerStampsModelKeyAndVersionOnTrackData() { ["enabled"] = LdValue.Of(true), ["variationKey"] = LdValue.Of("var-abc"), - ["version"] = LdValue.Of(7) + ["version"] = LdValue.Of(7), + ["modelKey"] = LdValue.Of("my-model"), + ["modelVersion"] = LdValue.Of(2) }), ["model"] = LdValue.ObjectFrom(new Dictionary { - ["name"] = LdValue.Of("gpt-4"), - ["modelKey"] = LdValue.Of("my-model"), - ["modelVersion"] = LdValue.Of(2) + ["name"] = LdValue.Of("gpt-4") }), ["provider"] = LdValue.ObjectFrom(new Dictionary { From 4c720d1622ba3439425de8277dca46764b2b4d5e Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Thu, 16 Jul 2026 14:39:23 -0700 Subject: [PATCH 3/4] Move modelKey/modelVersion parsing from ParseModel into ParseMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: ParseModel reaching into _ldMeta mixed concerns — it should only parse the model object. ParseMeta now returns a Meta record struct (replacing the 4-field tuple) that also carries ModelKey/ModelVersion, and callers pass those into ParseModel as plain parameters instead of letting it fetch _ldMeta itself. No behavior change; 242/242 tests pass. Co-Authored-By: Claude Fable 5 --- .../sdk/server-ai/src/Config/ConfigFactory.cs | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs index 8073c59a..fc438c3d 100644 --- a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs +++ b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs @@ -47,7 +47,7 @@ public LdAiCompletionConfig BuildCompletionConfig( return BuildCompletionFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var (enabled, variationKey, version, mode) = ParseMeta(ldValue); + var (enabled, variationKey, version, mode, modelKey, modelVersion) = ParseMeta(ldValue); if (mode != LdAiCompletionConfig.Mode) { @@ -57,7 +57,7 @@ public LdAiCompletionConfig BuildCompletionConfig( return BuildCompletionFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue); + var model = ParseModel(ldValue.Get("model"), modelKey, modelVersion); var provider = ParseProvider(ldValue.Get("provider")); var messages = interpolate ? InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key) @@ -123,7 +123,7 @@ public LdAiAgentConfig BuildAgentConfig( return BuildAgentFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var (enabled, variationKey, version, mode) = ParseMeta(ldValue); + var (enabled, variationKey, version, mode, modelKey, modelVersion) = ParseMeta(ldValue); if (mode != LdAiAgentConfig.Mode) { @@ -133,7 +133,7 @@ public LdAiAgentConfig BuildAgentConfig( return BuildAgentFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue); + var model = ParseModel(ldValue.Get("model"), modelKey, modelVersion); var provider = ParseProvider(ldValue.Get("provider")); var tools = ParseTools(ldValue.Get("tools")); var instructions = interpolate @@ -196,7 +196,7 @@ public LdAiJudgeConfig BuildJudgeConfig( return BuildJudgeFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var (enabled, variationKey, version, mode) = ParseMeta(ldValue); + var (enabled, variationKey, version, mode, modelKey, modelVersion) = ParseMeta(ldValue); if (mode != LdAiJudgeConfig.Mode) { @@ -206,7 +206,7 @@ public LdAiJudgeConfig BuildJudgeConfig( return BuildJudgeFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue); + var model = ParseModel(ldValue.Get("model"), modelKey, modelVersion); var provider = ParseProvider(ldValue.Get("provider")); var messages = interpolate ? InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key) @@ -310,7 +310,12 @@ private Func TrackerFactoryFor(Context context, graphKey); } - private static (bool Enabled, string VariationKey, int Version, string Mode) ParseMeta(LdValue value) + // _ldMeta carries modelKey/modelVersion alongside the variation-level fields (rather than + // the model object) so modelVersion can't be mistaken for the underlying LLM's own version. + private readonly record struct Meta( + bool Enabled, string VariationKey, int Version, string Mode, string ModelKey, int ModelVersion); + + private static Meta ParseMeta(LdValue value) { var meta = value.Get("_ldMeta"); var enabled = meta.Get("enabled").AsBool; @@ -320,19 +325,17 @@ private static (bool Enabled, string VariationKey, int Version, string Mode) Par // Default to the completion mode when _ldMeta.mode is missing or non-string: legacy // flags predate the mode tag and were always served as completion configs. var mode = meta.Get("mode").AsString ?? LdAiCompletionConfig.Mode; - return (enabled, variationKey, version, mode); + var modelKey = meta.Get("modelKey").AsString; + var modelVersionValue = meta.Get("modelVersion"); + var modelVersion = modelVersionValue.IsNull ? 1 : modelVersionValue.AsInt; + return new Meta(enabled, variationKey, version, mode, modelKey, modelVersion); } - private static LdAiConfigTypes.ModelConfig ParseModel(LdValue value) + private static LdAiConfigTypes.ModelConfig ParseModel(LdValue modelValue, string modelKey, int modelVersion) { - var modelValue = value.Get("model"); var name = modelValue.Get("name").AsString ?? ""; var parameters = LdValueObjectToDictionary(modelValue.Get("parameters")); var custom = LdValueObjectToDictionary(modelValue.Get("custom")); - var meta = value.Get("_ldMeta"); - var modelKey = meta.Get("modelKey").AsString; - var modelVersionValue = meta.Get("modelVersion"); - var modelVersion = modelVersionValue.IsNull ? 1 : modelVersionValue.AsInt; return new LdAiConfigTypes.ModelConfig(name, parameters, custom, modelKey, modelVersion); } From 86ad0d3259ea10fa697ac1a79643a837094ea21c Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Fri, 17 Jul 2026 12:45:21 -0700 Subject: [PATCH 4/4] Remove modelKey/modelVersion from the public ModelConfig type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per follow-up discussion: modelKey/modelVersion shouldn't be exposed on the model object a client sees at all — they'd read as properties of the model itself (e.g. a version like "5.4") rather than LaunchDarkly tracking metadata. They now live only where variationKey/version already live: as internal-only fields on LdAiConfig, passed straight into the tracker factory. ModelConfig, ParseModel, and the *ConfigDefault constructors go back to carrying only name/parameters/custom. Tests that asserted Model.ModelKey/ModelVersion are replaced with tracker-level assertions (mirroring the existing modelKey-present test); the "absent from payload" case now verifies via the tracker too. Co-Authored-By: Claude Fable 5 --- .../sdk/server-ai/src/Config/ConfigFactory.cs | 26 ++++++--- .../server-ai/src/Config/LdAiAgentConfig.cs | 4 +- .../src/Config/LdAiCompletionConfig.cs | 3 +- pkgs/sdk/server-ai/src/Config/LdAiConfig.cs | 20 ++++++- .../server-ai/src/Config/LdAiConfigTypes.cs | 17 +----- .../server-ai/src/Config/LdAiJudgeConfig.cs | 4 +- .../test/AgentGraphDefinitionTest.cs | 2 + .../server-ai/test/ConfigFactoryParserTest.cs | 53 +++++-------------- .../server-ai/test/LdAiConfigTrackerTest.cs | 2 + 9 files changed, 63 insertions(+), 68 deletions(-) diff --git a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs index fc438c3d..5cd3d135 100644 --- a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs +++ b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs @@ -57,7 +57,7 @@ public LdAiCompletionConfig BuildCompletionConfig( return BuildCompletionFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue.Get("model"), modelKey, modelVersion); + var model = ParseModel(ldValue.Get("model")); var provider = ParseProvider(ldValue.Get("provider")); var messages = interpolate ? InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key) @@ -70,6 +70,8 @@ public LdAiCompletionConfig BuildCompletionConfig( enabled, variationKey, version, + modelKey, + modelVersion, messages, tools, judgeConfiguration, @@ -95,6 +97,8 @@ private LdAiCompletionConfig BuildCompletionFromDefault( defaultValue.Enabled ?? true, variationKey: "", version: 1, + modelKey: null, + modelVersion: 1, messages, tools: ImmutableDictionary.Empty, defaultValue.JudgeConfiguration, @@ -133,7 +137,7 @@ public LdAiAgentConfig BuildAgentConfig( return BuildAgentFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue.Get("model"), modelKey, modelVersion); + var model = ParseModel(ldValue.Get("model")); var provider = ParseProvider(ldValue.Get("provider")); var tools = ParseTools(ldValue.Get("tools")); var instructions = interpolate @@ -146,6 +150,8 @@ public LdAiAgentConfig BuildAgentConfig( enabled, variationKey, version, + modelKey, + modelVersion, instructions, tools, model, @@ -169,6 +175,8 @@ internal LdAiAgentConfig BuildAgentFromDefault( defaultValue.Enabled ?? true, variationKey: "", version: 1, + modelKey: null, + modelVersion: 1, instructions, tools: ImmutableDictionary.Empty, defaultValue.Model, @@ -206,7 +214,7 @@ public LdAiJudgeConfig BuildJudgeConfig( return BuildJudgeFromDefault(key, defaultValue, mergedVars, trackerFactory, interpolate); } - var model = ParseModel(ldValue.Get("model"), modelKey, modelVersion); + var model = ParseModel(ldValue.Get("model")); var provider = ParseProvider(ldValue.Get("provider")); var messages = interpolate ? InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key) @@ -218,6 +226,8 @@ public LdAiJudgeConfig BuildJudgeConfig( enabled, variationKey, version, + modelKey, + modelVersion, messages, evaluationMetricKey, model, @@ -240,6 +250,8 @@ private LdAiJudgeConfig BuildJudgeFromDefault( defaultValue.Enabled ?? true, variationKey: "", version: 1, + modelKey: null, + modelVersion: 1, messages, defaultValue.EvaluationMetricKey, defaultValue.Model, @@ -305,8 +317,8 @@ private Func TrackerFactoryFor(Context context, context, cfg.Model?.Name, cfg.Provider?.Name, - cfg.Model?.ModelKey, - cfg.Model?.ModelVersion ?? 1, + cfg.ModelKey, + cfg.ModelVersion, graphKey); } @@ -331,12 +343,12 @@ private static Meta ParseMeta(LdValue value) return new Meta(enabled, variationKey, version, mode, modelKey, modelVersion); } - private static LdAiConfigTypes.ModelConfig ParseModel(LdValue modelValue, string modelKey, int modelVersion) + private static LdAiConfigTypes.ModelConfig ParseModel(LdValue modelValue) { var name = modelValue.Get("name").AsString ?? ""; var parameters = LdValueObjectToDictionary(modelValue.Get("parameters")); var custom = LdValueObjectToDictionary(modelValue.Get("custom")); - return new LdAiConfigTypes.ModelConfig(name, parameters, custom, modelKey, modelVersion); + return new LdAiConfigTypes.ModelConfig(name, parameters, custom); } private static LdAiConfigTypes.ProviderConfig ParseProvider(LdValue providerValue) diff --git a/pkgs/sdk/server-ai/src/Config/LdAiAgentConfig.cs b/pkgs/sdk/server-ai/src/Config/LdAiAgentConfig.cs index 152f021f..e009ff75 100644 --- a/pkgs/sdk/server-ai/src/Config/LdAiAgentConfig.cs +++ b/pkgs/sdk/server-ai/src/Config/LdAiAgentConfig.cs @@ -41,13 +41,15 @@ internal LdAiAgentConfig( bool enabled, string variationKey, int version, + string modelKey, + int modelVersion, string instructions, IReadOnlyDictionary tools, LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider, LdAiConfigTypes.JudgeConfiguration judgeConfiguration, Func trackerFactory) - : base(key, enabled, variationKey, version, model, provider, trackerFactory) + : base(key, enabled, variationKey, version, modelKey, modelVersion, model, provider, trackerFactory) { Instructions = instructions; Tools = tools?.ToImmutableDictionary() ?? ImmutableDictionary.Empty; diff --git a/pkgs/sdk/server-ai/src/Config/LdAiCompletionConfig.cs b/pkgs/sdk/server-ai/src/Config/LdAiCompletionConfig.cs index 92b48da7..9faef003 100644 --- a/pkgs/sdk/server-ai/src/Config/LdAiCompletionConfig.cs +++ b/pkgs/sdk/server-ai/src/Config/LdAiCompletionConfig.cs @@ -39,11 +39,12 @@ public sealed class LdAiCompletionConfig : LdAiConfig public LdAiConfigTypes.JudgeConfiguration JudgeConfiguration { get; } internal LdAiCompletionConfig(string key, bool enabled, string variationKey, int version, + string modelKey, int modelVersion, IEnumerable messages, IReadOnlyDictionary tools, LdAiConfigTypes.JudgeConfiguration judgeConfiguration, LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider, Func trackerFactory) - : base(key, enabled, variationKey, version, model, provider, trackerFactory) + : base(key, enabled, variationKey, version, modelKey, modelVersion, model, provider, trackerFactory) { Messages = messages?.ToList() ?? new List(); Tools = tools ?? ImmutableDictionary.Empty; diff --git a/pkgs/sdk/server-ai/src/Config/LdAiConfig.cs b/pkgs/sdk/server-ai/src/Config/LdAiConfig.cs index 6a7bee19..3d9e109b 100644 --- a/pkgs/sdk/server-ai/src/Config/LdAiConfig.cs +++ b/pkgs/sdk/server-ai/src/Config/LdAiConfig.cs @@ -40,11 +40,23 @@ public abstract class LdAiConfig /// internal int Version { get; } + /// + /// This field meant for internal LaunchDarkly usage. Not exposed on so + /// it can't be mistaken for the underlying LLM's own version (e.g. "GPT-5.4"). + /// + internal string ModelKey { get; } + + /// + /// This field meant for internal LaunchDarkly usage. + /// + internal int ModelVersion { get; } + /// /// Factory that produces a tracker for the config. The factory is mode-agnostic — it /// operates only on the shared fields (, , - /// , , ), so the - /// same tracker class serves all config modes. + /// , , , + /// , ), so the same tracker class serves + /// all config modes. /// private readonly Func _trackerFactory; @@ -64,6 +76,8 @@ private protected LdAiConfig( bool enabled, string variationKey, int version, + string modelKey, + int modelVersion, LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider, Func trackerFactory) @@ -72,6 +86,8 @@ private protected LdAiConfig( Enabled = enabled; VariationKey = variationKey ?? ""; Version = version; + ModelKey = modelKey; + ModelVersion = modelVersion; Model = model ?? new LdAiConfigTypes.ModelConfig("", new Dictionary(), new Dictionary()); Provider = provider ?? new LdAiConfigTypes.ProviderConfig(""); _trackerFactory = trackerFactory ?? throw new ArgumentNullException(nameof(trackerFactory)); diff --git a/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs b/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs index 25115bba..af8875c0 100644 --- a/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs +++ b/pkgs/sdk/server-ai/src/Config/LdAiConfigTypes.cs @@ -71,23 +71,10 @@ public sealed record ModelConfig /// public readonly IReadOnlyDictionary Custom; - /// - /// The stable, unique key of the model (used for direct lookup; distinct from - /// , which is not guaranteed unique). - /// - public readonly string ModelKey; - - /// - /// The pinned version of the model that this config variation references. - /// - public readonly int ModelVersion; - internal ModelConfig( string name, IReadOnlyDictionary parameters, - IReadOnlyDictionary custom, - string modelKey = null, - int modelVersion = 1) + IReadOnlyDictionary custom) { Name = name; // Materialize into an ImmutableDictionary so a consumer that downcasts to @@ -96,8 +83,6 @@ internal ModelConfig( // NotSupportedException at runtime, matching the typed read-only promise. Parameters = parameters?.ToImmutableDictionary() ?? ImmutableDictionary.Empty; Custom = custom?.ToImmutableDictionary() ?? ImmutableDictionary.Empty; - ModelKey = modelKey; - ModelVersion = modelVersion; } } diff --git a/pkgs/sdk/server-ai/src/Config/LdAiJudgeConfig.cs b/pkgs/sdk/server-ai/src/Config/LdAiJudgeConfig.cs index de32cb9d..4371d359 100644 --- a/pkgs/sdk/server-ai/src/Config/LdAiJudgeConfig.cs +++ b/pkgs/sdk/server-ai/src/Config/LdAiJudgeConfig.cs @@ -36,12 +36,14 @@ internal LdAiJudgeConfig( bool enabled, string variationKey, int version, + string modelKey, + int modelVersion, IEnumerable messages, string evaluationMetricKey, LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider, Func trackerFactory) - : base(key, enabled, variationKey, version, model, provider, trackerFactory) + : base(key, enabled, variationKey, version, modelKey, modelVersion, model, provider, trackerFactory) { Messages = messages?.ToList() ?? new List(); EvaluationMetricKey = evaluationMetricKey; diff --git a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs index 54bb8d5f..67bfc821 100644 --- a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs +++ b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs @@ -23,6 +23,8 @@ private static LdAiAgentConfig MakeAgentConfig(string key, bool enabled = true) enabled: enabled, variationKey: "v1", version: 1, + modelKey: null, + modelVersion: 1, instructions: null, tools: new Dictionary(), model: null, diff --git a/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs b/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs index a2fb924e..ae434f27 100644 --- a/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs +++ b/pkgs/sdk/server-ai/test/ConfigFactoryParserTest.cs @@ -208,44 +208,11 @@ public void ParseAllFields_WithNoNewFields_ReturnsNullOrEmptyWithoutError() } [Fact] - public void CompletionConfigReadsModelKeyAndVersionFromFlag() - { - var mockClient = new Mock(); - var mockLogger = new Mock(); - mockClient.Setup(x => x.GetLogger()).Returns(mockLogger.Object); - mockClient.Setup(x => - x.JsonVariation("model-config-with-key-version", It.IsAny(), It.IsAny())) - .Returns(LdValue.ObjectFrom(new Dictionary - { - ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary - { - ["enabled"] = LdValue.Of(true), - ["variationKey"] = LdValue.Of("v1"), - ["version"] = LdValue.Of(1), - ["modelKey"] = LdValue.Of("my-model"), - ["modelVersion"] = LdValue.Of(2) - }), - ["model"] = LdValue.ObjectFrom(new Dictionary - { - ["name"] = LdValue.Of("gpt-4") - }), - ["provider"] = LdValue.ObjectFrom(new Dictionary - { - ["name"] = LdValue.Of("openai") - }), - ["messages"] = LdValue.ArrayOf() - })); - - var client = new LdAiClient(mockClient.Object); - var result = client.CompletionConfig("model-config-with-key-version", Context.New("user-key")); - - Assert.Equal("my-model", result.Model.ModelKey); - Assert.Equal(2, result.Model.ModelVersion); - } - - [Fact] - public void CompletionConfigDefaultsModelVersionWhenAbsent() + public void CreateTrackerDefaultsModelVersionAndOmitsModelKeyWhenAbsentFromFlag() { + // modelKey/modelVersion are intentionally not exposed on Model (they'd read as + // properties of the LLM itself, e.g. a version like "5.4"); the only place they + // surface is the tracker's stamped event data, mirroring variationKey/version. var mockClient = new Mock(); var mockLogger = new Mock(); mockClient.Setup(x => x.GetLogger()).Returns(mockLogger.Object); @@ -271,10 +238,16 @@ public void CompletionConfigDefaultsModelVersionWhenAbsent() })); var client = new LdAiClient(mockClient.Object); - var result = client.CompletionConfig("model-config-no-version", Context.New("user-key")); + var context = Context.New("user-key"); + var config = client.CompletionConfig("model-config-no-version", context); + var tracker = config.CreateTracker(); + tracker.TrackSuccess(); - Assert.Null(result.Model.ModelKey); - Assert.Equal(1, result.Model.ModelVersion); + mockClient.Verify(x => x.Track("$ld:ai:generation:success", context, + It.Is(d => + d.Get("modelKey").IsNull && + d.Get("modelVersion").AsInt == 1), + 1.0f), Times.Once); } [Fact] diff --git a/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs b/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs index f701ad25..0ecc7a24 100644 --- a/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs +++ b/pkgs/sdk/server-ai/test/LdAiConfigTrackerTest.cs @@ -25,6 +25,8 @@ private static LdAiCompletionConfig BuildConfig(ILaunchDarklyClient client, stri enabled: false, variationKey: "", version: 1, + modelKey: null, + modelVersion: 1, messages: new List(), tools: new Dictionary(), judgeConfiguration: null,