Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions ldai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,40 +40,77 @@ type Client struct {
logger interfaces.LDLoggers
}

const (
sdkInfoEvent = "$ld:ai:sdk:info"
usageCompletionConfig = "$ld:ai:usage:completion-config"
usageJudgeConfig = "$ld:ai:usage:judge-config"
)

// NewClient creates a new AI Client. The provided SDK interface must not be nil. The client will use the provided SDK's
// loggers to log warnings and errors.
func NewClient(sdk ServerSDK) (*Client, error) {
if sdk == nil {
return nil, fmt.Errorf("sdk must not be nil")
}
return &Client{
c := &Client{
sdk: sdk,
logger: sdk.Loggers(),
}, nil
}
if err := c.trackSDKInfo(); err != nil {
c.logger.Warnf("AI Client: failed to track SDK info: %v", err)
}
return c, nil
}

func (c *Client) trackSDKInfo() error {
ctx, err := ldcontext.NewBuilder("ld-internal-tracking").Kind("ld_ai").Anonymous(true).TryBuild()
if err != nil {
return err
}
data := ldvalue.ObjectBuild().
Set("aiSdkName", ldvalue.String(SDKName)).
Set("aiSdkVersion", ldvalue.String(Version)).
Set("aiSdkLanguage", ldvalue.String(SDKLanguage)).
Build()
return c.sdk.TrackMetric(sdkInfoEvent, ctx, 1, data)
}

func (c *Client) logConfigWarning(key string, format string, args ...interface{}) {
prefix := "AI Config '" + key + "': "
c.logger.Warnf(prefix+format, args...)
}

// Config evaluates an AI Config named by a given key for the given context.
// CompletionConfig retrieves and processes a Completion AI Config based on the provided key, LaunchDarkly context,
// and variables. This includes the model configuration and the customized messages.
//
// The config's messages will undergo Mustache template interpolation using the provided variables, which may be
// nil. If the config cannot be evaluated or LaunchDarkly is unreachable, the default value is returned. Note that
// the messages in the default will not undergo template interpolation.
//
// To send analytic events to LaunchDarkly related to the AI Config, call methods on the returned Tracker.
func (c *Client) Config(
func (c *Client) CompletionConfig(
key string,
context ldcontext.Context,
defaultValue Config,
variables map[string]interface{},
) (Config, *Tracker) {
_ = c.sdk.TrackMetric("$ld:ai:config:function:single", context, 1, ldvalue.String(key))
data := ldvalue.ObjectBuild().Set("configKey", ldvalue.String(key)).Build()
_ = c.sdk.TrackMetric(usageCompletionConfig, context, 1, data)
return c.evaluateConfig(key, context, defaultValue, variables)
}

// Config evaluates an AI Config named by a given key for the given context.
//
// Deprecated: Use CompletionConfig instead.
func (c *Client) Config(
key string,
context ldcontext.Context,
defaultValue Config,
variables map[string]interface{},
) (Config, *Tracker) {
return c.CompletionConfig(key, context, defaultValue, variables)
}

// evaluateConfig fetches and interpolates an AI Config without emitting any metric.
// Callers (Config, JudgeConfig) are meant to emit their own metric before calling this.
func (c *Client) evaluateConfig(
Expand Down Expand Up @@ -189,21 +226,25 @@ func interpolateTemplate(template string, variables map[string]interface{}) (str
return m.RenderString(variables)
}

// JudgeConfig evaluates an AI Config, tracking it as a judge function. See Config for details.
// JudgeConfig retrieves and processes a Judge AI Config based on the provided key, LaunchDarkly context, and
// variables. This includes the model configuration and the customized messages for evaluation.
//
// This method extends the provided variables with reserved judge variables:
// - "message_history": "{{message_history}}"
// - "response_to_evaluate": "{{response_to_evaluate}}"
//
// These literal placeholder strings preserve the Mustache templates through the first interpolation
// (during config fetch), allowing Judge.Evaluate() to perform a second interpolation with actual values.
//
// To send analytic events to LaunchDarkly related to the AI Config, call methods on the returned Tracker.
func (c *Client) JudgeConfig(
key string,
context ldcontext.Context,
defaultValue Config,
variables map[string]interface{},
) (Config, *Tracker) {
_ = c.sdk.TrackMetric("$ld:ai:judge:function:single", context, 1, ldvalue.String(key))
data := ldvalue.ObjectBuild().Set("configKey", ldvalue.String(key)).Build()
_ = c.sdk.TrackMetric(usageJudgeConfig, context, 1, data)

// Extend variables with reserved judge placeholders
extendedVariables := make(map[string]interface{})
Expand Down
72 changes: 64 additions & 8 deletions ldai/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,24 @@ func TestNewClientReturnsErrorWhenSDKIsNil(t *testing.T) {
}

func TestNewClient(t *testing.T) {
client, err := NewClient(newMockSDK(nil, nil))
mockSDK := newMockSDK(nil, nil)
client, err := NewClient(mockSDK)
require.NoError(t, err)
require.NotNil(t, client)

// Verify SDK info event was fired on construction.
require.Len(t, mockSDK.events, 1)
evt := mockSDK.events[0]
assert.Equal(t, "$ld:ai:sdk:info", evt.eventName)
assert.Equal(t, float64(1), evt.metricValue)
assert.Equal(t, "launchdarkly-go-server-sdk-ai", evt.data.GetByKey("aiSdkName").StringValue())
assert.Equal(t, Version, evt.data.GetByKey("aiSdkVersion").StringValue())
assert.Equal(t, "go", evt.data.GetByKey("aiSdkLanguage").StringValue())

// Verify the context is anonymous with kind ld_ai.
assert.Equal(t, "ld-internal-tracking", evt.context.Key())
assert.True(t, evt.context.Anonymous())
assert.Equal(t, ldcontext.Kind("ld_ai"), evt.context.Kind())
}

func TestEvalErrorReturnsDefault(t *testing.T) {
Expand Down Expand Up @@ -302,12 +317,48 @@ func TestCanSetDefaultConfigFields(t *testing.T) {
assert.Equal(t, datamodel.System, msg[1].Role)
}

func TestConfigMethodTracking(t *testing.T) {
func TestCompletionConfigMethodTracking(t *testing.T) {
mockSDK := newMockSDK(nil, nil)
client, err := NewClient(mockSDK)
require.NoError(t, err)
require.NotNil(t, client)

// Clear the SDK info event from construction.
mockSDK.events = nil

defaultConfig := NewConfig().WithEnabled(false).Build()
context := ldcontext.New("user-key")
configKey := "test-config-key"

config, tracker := client.CompletionConfig(configKey, context, defaultConfig, nil)

require.NotNil(t, config)
require.NotNil(t, tracker)

expectedData := ldvalue.ObjectBuild().Set("configKey", ldvalue.String(configKey)).Build()
expectedEvents := []mockEvent{
{
eventName: "$ld:ai:usage:completion-config",
context: context,
metricValue: 1,
data: expectedData,
},
}

assert.ElementsMatch(t, expectedEvents, mockSDK.events)
}

// TestDeprecatedConfigDelegatesToCompletionConfig verifies the deprecated Config method delegates
// to CompletionConfig and emits the same usage event.
func TestDeprecatedConfigDelegatesToCompletionConfig(t *testing.T) {
mockSDK := newMockSDK(nil, nil)
client, err := NewClient(mockSDK)
require.NoError(t, err)
require.NotNil(t, client)

// Clear the SDK info event from construction.
mockSDK.events = nil

defaultConfig := NewConfig().WithEnabled(false).Build()
context := ldcontext.New("user-key")
configKey := "test-config-key"
Expand All @@ -317,20 +368,21 @@ func TestConfigMethodTracking(t *testing.T) {
require.NotNil(t, config)
require.NotNil(t, tracker)

expectedData := ldvalue.ObjectBuild().Set("configKey", ldvalue.String(configKey)).Build()
expectedEvents := []mockEvent{
{
eventName: "$ld:ai:config:function:single",
eventName: "$ld:ai:usage:completion-config",
context: context,
metricValue: 1,
data: ldvalue.String(configKey),
data: expectedData,
},
}

assert.ElementsMatch(t, expectedEvents, mockSDK.events)
}

// TestJudgeConfigMethodTracking verifies that JudgeConfig emits only the judge metric,
// not the config metric, so judge evaluations are not double-counted on the dashboard.
// not the completion-config metric, so judge evaluations are not double-counted on the dashboard.
func TestJudgeConfigMethodTracking(t *testing.T) {
json := []byte(`{
"_ldMeta": {"variationKey": "1", "enabled": true},
Expand All @@ -343,6 +395,9 @@ func TestJudgeConfigMethodTracking(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, client)

// Clear the SDK info event from construction.
mockSDK.events = nil

defaultConfig := Disabled()
context := ldcontext.New("user-key")
configKey := "judge-config-key"
Expand All @@ -353,16 +408,17 @@ func TestJudgeConfigMethodTracking(t *testing.T) {
require.NotNil(t, tracker)

// Only the judge metric should be emitted; evaluateConfig does not emit any metric.
expectedData := ldvalue.ObjectBuild().Set("configKey", ldvalue.String(configKey)).Build()
expectedEvents := []mockEvent{
{
eventName: "$ld:ai:judge:function:single",
eventName: "$ld:ai:usage:judge-config",
context: context,
metricValue: 1,
data: ldvalue.String(configKey),
data: expectedData,
},
}
assert.ElementsMatch(t, expectedEvents, mockSDK.events,
"JudgeConfig must not emit $ld:ai:config:function:single to avoid double-counting")
"JudgeConfig must not emit $ld:ai:usage:completion-config to avoid double-counting")
}

func TestCanSetModelParameters(t *testing.T) {
Expand Down
12 changes: 10 additions & 2 deletions ldai/package_info.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
// Package ldai contains an AI SDK suitable for usage with generative AI applications.
package ldai

// Version is the current version string of the ldai package. This is updated by our release scripts.
const Version = "0.8.0" // {{ x-release-please-version }}
const (
// Version is the current version string of the ldai package. This is updated by our release scripts.
Version = "0.8.0" // {{ x-release-please-version }}

// SDKName is the canonical name of this AI SDK package.
SDKName = "launchdarkly-go-server-sdk-ai"

// SDKLanguage is the programming language of this AI SDK.
SDKLanguage = "go"
)