Skip to content

Commit 69418c8

Browse files
authored
chore: Fix ldctx silent override and Add Tools to completion config (3 of 6) (#281)
BEGIN_COMMIT_OVERRIDE feat: Add Tools property to LdAiCompletionConfig — parses the same tools block agents use, exposes IReadOnlyDictionary<string, ToolConfig> (empty when absent) fix: Silently override 'ldctx' in user-supplied template variables instead of warning and discarding it — the SDK context always wins, matches cross-SDK behavior END_COMMIT_OVERRIDE ## Summary Two tightly-scoped changes to the **Completion** path: ### 1. `Tools` on `LdAiCompletionConfig` `LdAiCompletionConfig` now exposes `Tools` (an `IReadOnlyDictionary<string, ToolConfig>`), populated from the `tools` block on the variation JSON. `ConfigFactory.BuildCompletionConfig` and `BuildFromDefault` both pass it through; on the default path it's an empty dictionary. `tools` is a model-level field rather than an agent-only one, so it now lives on both completion and agent configs. ### 2. Silent `ldctx` override `ConfigFactory.MergeVariables` previously warned-and-dropped when a caller passed an `ldctx` key in their template variables. That diverged from the other SDKs (python, js-core, ruby) where `ldctx` is silently overwritten by the LaunchDarkly context. The behavior is now: - User-supplied variables are seeded first. - `ldctx` is added last, silently overriding any caller-supplied value. - No warning is logged. The caller's `ldctx` value never reaches the Mustache template. The reserved-key warning is gone; the comment explaining the override is moved to the line that does it. Practical effect: callers who try to set `ldctx` explicitly no longer get a confusing "this was ignored" log line, and the template always sees the real LD context's attributes. ## Migration `Tools` is additive — no migration needed; existing callers that ignore it see no change. The `ldctx` warning change is a **silent behavior change** for any caller who was previously relying on the warning to spot the reserved-key collision. If a caller was depending on the warning to flag an unintended override, they will no longer see it. In practice this matches what all other SDKs already do. ## Test plan - [ ] `dotnet build` succeeds across `netstandard2.0`, `net462`, `net8.0` - [ ] `dotnet test --framework net8.0` passes - [ ] `LdAiClientTest.LdCtxOverrideIsSilent` — verifies the SDK context's `key` wins over a user-supplied `ldctx`, with **no** warning emitted - [ ] `LdAiClientTest.ToolsPopulatedOnCompletionConfig` — covers a `tools` block on a completion config flag, including `customParameters` - [ ] `LdAiClientTest.ToolsEmptyWhenAbsentFromJson` — covers the missing-`tools` case, asserting an empty (non-null) dictionary - [ ] `LdAiConfigTrackerTest` updated to pass `tools` through the new constructor signature - [ ] Reviewer confirms the silent-override semantics match python / js-core / ruby <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Additive public API and template-variable behavior aligned with other SDKs; only callers who relied on the removed `ldctx` warning are affected. > > **Overview** > **Completion configs** now surface **`Tools`** (parsed from variation JSON via shared `ParseTools`, empty when missing) and **`JudgeConfiguration`** (parsed when present, carried through caller defaults and `LdAiCompletionConfigDefault` builder/`ToLdValue` roundtrips). > > **Template variables:** `MergeVariables` no longer logs a warning or drops a caller-supplied **`ldctx`** key—it seeds user vars first, then always sets **`ldctx`** from the LaunchDarkly context so Mustache templates match other SDKs. > > Tests cover silent `ldctx` override, tools presence/absence, judge config parsing and default fallbacks, and updated `LdAiCompletionConfig` constructor usage in tracker tests. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fc3487e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a744d60 commit 69418c8

5 files changed

Lines changed: 319 additions & 13 deletions

File tree

pkgs/sdk/server-ai/src/Config/ConfigFactory.cs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,17 @@ public LdAiCompletionConfig BuildCompletionConfig(
5959
var model = ParseModel(ldValue.Get("model"));
6060
var provider = ParseProvider(ldValue.Get("provider"));
6161
var messages = InterpolateMessages(ParseMessages(ldValue.Get("messages")), mergedVars, key);
62+
var tools = ParseTools(ldValue.Get("tools"));
63+
var judgeConfiguration = ParseJudgeConfiguration(ldValue.Get("judgeConfiguration"));
6264

6365
return new LdAiCompletionConfig(
6466
key,
6567
enabled,
6668
variationKey,
6769
version,
6870
messages,
71+
tools,
72+
judgeConfiguration,
6973
model,
7074
provider,
7175
trackerFactory);
@@ -86,6 +90,8 @@ private LdAiCompletionConfig BuildCompletionFromDefault(
8690
variationKey: "",
8791
version: 1,
8892
messages,
93+
tools: ImmutableDictionary<string, LdAiConfigTypes.Tool>.Empty,
94+
defaultValue.JudgeConfiguration,
8995
defaultValue.Model,
9096
defaultValue.Provider,
9197
trackerFactory);
@@ -399,25 +405,18 @@ private static LdAiConfigTypes.Role ParseRole(string roleString)
399405
return LdAiConfigTypes.Role.User;
400406
}
401407

402-
private IReadOnlyDictionary<string, object> MergeVariables(
408+
private static IReadOnlyDictionary<string, object> MergeVariables(
403409
IReadOnlyDictionary<string, object> userVariables, Context context)
404410
{
405-
// Seed with user variables; the special "ldctx" key is reserved for the LaunchDarkly
406-
// context and always overrides any user-supplied value of that key.
407411
var merged = new Dictionary<string, object>();
408412
if (userVariables != null)
409413
{
410414
foreach (var kvp in userVariables)
411415
{
412-
if (kvp.Key == LdContextVariable)
413-
{
414-
_logger.Warn(
415-
"AI model config variables contains 'ldctx' key, which is reserved; this key will be the value of the LaunchDarkly context");
416-
continue;
417-
}
418416
merged[kvp.Key] = kvp.Value;
419417
}
420418
}
419+
// ldctx is always added last, silently overriding any user-supplied value.
421420
merged[LdContextVariable] = GetAllAttributes(context);
422421
return merged;
423422
}

pkgs/sdk/server-ai/src/Config/LdAiCompletionConfig.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Collections.Immutable;
34
using System.Linq;
45
using LaunchDarkly.Sdk.Server.Ai.Interfaces;
56

@@ -27,11 +28,25 @@ public sealed class LdAiCompletionConfig : LdAiConfig
2728
/// </summary>
2829
public IReadOnlyList<LdAiConfigTypes.Message> Messages { get; }
2930

31+
/// <summary>
32+
/// The tools available to the model, keyed by tool name.
33+
/// </summary>
34+
public IReadOnlyDictionary<string, LdAiConfigTypes.Tool> Tools { get; }
35+
36+
/// <summary>
37+
/// The judge configuration attached to this completion config, or <c>null</c> if none is configured.
38+
/// </summary>
39+
public LdAiConfigTypes.JudgeConfiguration JudgeConfiguration { get; }
40+
3041
internal LdAiCompletionConfig(string key, bool enabled, string variationKey, int version,
31-
IEnumerable<LdAiConfigTypes.Message> messages, LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider,
42+
IEnumerable<LdAiConfigTypes.Message> messages, IReadOnlyDictionary<string, LdAiConfigTypes.Tool> tools,
43+
LdAiConfigTypes.JudgeConfiguration judgeConfiguration,
44+
LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider,
3245
Func<LdAiConfig, ILdAiConfigTracker> trackerFactory)
3346
: base(key, enabled, variationKey, version, model, provider, trackerFactory)
3447
{
3548
Messages = messages?.ToList() ?? new List<LdAiConfigTypes.Message>();
49+
Tools = tools ?? ImmutableDictionary<string, LdAiConfigTypes.Tool>.Empty;
50+
JudgeConfiguration = judgeConfiguration;
3651
}
3752
}

pkgs/sdk/server-ai/src/Config/LdAiCompletionConfigDefault.cs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class Builder
2222
{
2323
private bool _enabled;
2424
private readonly List<LdAiConfigTypes.Message> _messages;
25+
private LdAiConfigTypes.JudgeConfiguration _judgeConfiguration;
2526
private readonly Dictionary<string, LdValue> _modelParams;
2627
private readonly Dictionary<string, LdValue> _customModelParams;
2728
private string _providerName;
@@ -31,6 +32,7 @@ internal Builder()
3132
{
3233
_enabled = true;
3334
_messages = new List<LdAiConfigTypes.Message>();
35+
_judgeConfiguration = null;
3436
_modelParams = new Dictionary<string, LdValue>();
3537
_customModelParams = new Dictionary<string, LdValue>();
3638
_providerName = "";
@@ -118,6 +120,17 @@ public Builder SetModelProviderName(string name)
118120
return this;
119121
}
120122

123+
/// <summary>
124+
/// Sets the judge configuration for this completion config default.
125+
/// </summary>
126+
/// <param name="config">the judge configuration</param>
127+
/// <returns>a new builder</returns>
128+
public Builder SetJudgeConfiguration(LdAiConfigTypes.JudgeConfiguration config)
129+
{
130+
_judgeConfiguration = config;
131+
return this;
132+
}
133+
121134
/// <summary>
122135
/// Builds the LdAiCompletionConfigDefault instance.
123136
/// </summary>
@@ -129,7 +142,7 @@ public LdAiCompletionConfigDefault Build()
129142
new Dictionary<string, LdValue>(_modelParams),
130143
new Dictionary<string, LdValue>(_customModelParams));
131144
var provider = new LdAiConfigTypes.ProviderConfig(_providerName);
132-
return new LdAiCompletionConfigDefault(_enabled, _messages, model, provider);
145+
return new LdAiCompletionConfigDefault(_enabled, _messages, _judgeConfiguration, model, provider);
133146
}
134147
}
135148

@@ -138,11 +151,18 @@ public LdAiCompletionConfigDefault Build()
138151
/// </summary>
139152
public IReadOnlyList<LdAiConfigTypes.Message> Messages { get; }
140153

154+
/// <summary>
155+
/// The judge configuration for this completion config default, or <c>null</c> if not specified.
156+
/// </summary>
157+
public LdAiConfigTypes.JudgeConfiguration JudgeConfiguration { get; }
158+
141159
internal LdAiCompletionConfigDefault(bool? enabled, IEnumerable<LdAiConfigTypes.Message> messages,
160+
LdAiConfigTypes.JudgeConfiguration judgeConfiguration,
142161
LdAiConfigTypes.ModelConfig model, LdAiConfigTypes.ProviderConfig provider)
143162
: base(enabled, model, provider)
144163
{
145164
Messages = messages?.ToList() ?? new List<LdAiConfigTypes.Message>();
165+
JudgeConfiguration = judgeConfiguration;
146166
}
147167

148168
internal LdValue ToLdValue()
@@ -153,7 +173,7 @@ internal LdValue ToLdValue()
153173
["mode"] = LdValue.Of(LdAiCompletionConfig.Mode)
154174
};
155175

156-
return LdValue.ObjectFrom(new Dictionary<string, LdValue>
176+
var root = new Dictionary<string, LdValue>
157177
{
158178
{ "_ldMeta", LdValue.ObjectFrom(metaFields) },
159179
{ "messages", LdValue.ArrayFrom(Messages.Select(m => LdValue.ObjectFrom(new Dictionary<string, LdValue>
@@ -171,7 +191,23 @@ internal LdValue ToLdValue()
171191
{
172192
{ "name", LdValue.Of(Provider.Name) }
173193
}) }
174-
});
194+
};
195+
196+
if (JudgeConfiguration != null)
197+
{
198+
root["judgeConfiguration"] = LdValue.ObjectFrom(new Dictionary<string, LdValue>
199+
{
200+
{ "judges", LdValue.ArrayFrom(JudgeConfiguration.Judges.Select(j =>
201+
LdValue.ObjectFrom(new Dictionary<string, LdValue>
202+
{
203+
{ "key", LdValue.Of(j.Key) },
204+
{ "samplingRate", LdValue.Of(j.SamplingRate) }
205+
})))
206+
}
207+
});
208+
}
209+
210+
return LdValue.ObjectFrom(root);
175211
}
176212

177213
/// <summary>

0 commit comments

Comments
 (0)