diff --git a/ldai/client.go b/ldai/client.go index a1e4c9fa..b5c797cd 100644 --- a/ldai/client.go +++ b/ldai/client.go @@ -40,16 +40,39 @@ 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{}) { @@ -57,23 +80,37 @@ func (c *Client) logConfigWarning(key string, format string, args ...interface{} 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( @@ -189,7 +226,8 @@ 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}}" @@ -197,13 +235,16 @@ func interpolateTemplate(template string, variables map[string]interface{}) (str // // 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{}) diff --git a/ldai/client_test.go b/ldai/client_test.go index d145e5b5..3a794e1e 100644 --- a/ldai/client_test.go +++ b/ldai/client_test.go @@ -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) { @@ -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" @@ -317,12 +368,13 @@ 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, }, } @@ -330,7 +382,7 @@ func TestConfigMethodTracking(t *testing.T) { } // 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}, @@ -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" @@ -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) { diff --git a/ldai/package_info.go b/ldai/package_info.go index 117a425e..2b24d250 100644 --- a/ldai/package_info.go +++ b/ldai/package_info.go @@ -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" +)