diff --git a/go.mod b/go.mod index b18dd8da..9c0160c4 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( ) require ( - github.com/launchdarkly/api-client-go/v17 v17.1.0 + github.com/launchdarkly/api-client-go/v17 v17.2.0 golang.org/x/sync v0.18.0 ) diff --git a/go.sum b/go.sum index e3066bda..6439a42e 100644 --- a/go.sum +++ b/go.sum @@ -540,8 +540,8 @@ github.com/kulti/thelper v0.5.0/go.mod h1:vMu2Cizjy/grP+jmsvOFDx1kYP6+PD1lqg4Yu5 github.com/kunwardeep/paralleltest v1.0.3/go.mod h1:vLydzomDFpk7yu5UX02RmP0H8QfRPOV/oFhWN85Mjb4= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kyoh86/exportloopref v0.1.8/go.mod h1:1tUcJeiioIs7VWe5gcOObrux3lb66+sBqGZrRkMwPgg= -github.com/launchdarkly/api-client-go/v17 v17.1.0 h1:IbR5UDLKBmff0eRBSD3UgVDfgnifOepBIe4gqivMaec= -github.com/launchdarkly/api-client-go/v17 v17.1.0/go.mod h1:lMTmhEjepXfam8xm8b0ERBJbV9g8vdu9nbKueDXcB5o= +github.com/launchdarkly/api-client-go/v17 v17.2.0 h1:5CJxDaL7ZgqALAcohNUMlV7hfXR65s2czZ4XmZjW/qI= +github.com/launchdarkly/api-client-go/v17 v17.2.0/go.mod h1:lMTmhEjepXfam8xm8b0ERBJbV9g8vdu9nbKueDXcB5o= github.com/ldez/gomoddirectives v0.2.2/go.mod h1:cpgBogWITnCfRq2qGoDkKMEVSaarhdBr6g8G04uz6d0= github.com/ldez/tagliatelle v0.3.0/go.mod h1:8s6WJQwEYHbKZDsp/LjArytKOG8qaMrKQQ3mFukHs88= github.com/leonklingele/grouper v1.1.0/go.mod h1:uk3I3uDfi9B6PeUjsCKi6ndcf63Uy7snXgR4yDYQVDY= diff --git a/launchdarkly/ai_model_config_helper.go b/launchdarkly/ai_model_config_helper.go new file mode 100644 index 00000000..b71d1b6e --- /dev/null +++ b/launchdarkly/ai_model_config_helper.go @@ -0,0 +1,194 @@ +package launchdarkly + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + ldapi "github.com/launchdarkly/api-client-go/v17" +) + +func baseAIModelConfigSchema(isDataSource bool) map[string]*schema.Schema { + schemaMap := map[string]*schema.Schema{ + PROJECT_KEY: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: addForceNewDescription("The AI model config's project key. A change in this field will force the destruction of the existing resource and the creation of a new one.", !isDataSource), + ValidateDiagFunc: validateKey(), + }, + KEY: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validateKey(), + Description: addForceNewDescription("The unique key that references the AI model config. A change in this field will force the destruction of the existing resource and the creation of a new one.", !isDataSource), + }, + NAME: { + Type: schema.TypeString, + Required: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("The human-friendly name for the AI model config.", !isDataSource), + }, + ID: { + Type: schema.TypeString, + Required: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Identifier for the model, for use with third party providers.", !isDataSource), + }, + MODEL_PROVIDER: { + Type: schema.TypeString, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Provider for the model.", !isDataSource), + }, + ICON: { + Type: schema.TypeString, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Icon for the model.", !isDataSource), + }, + PARAMS: { + Type: schema.TypeMap, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: addForceNewDescription("Model parameters as a map of key-value pairs.", !isDataSource), + }, + CUSTOM_PARAMS: { + Type: schema.TypeMap, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: addForceNewDescription("Custom model parameters as a map of key-value pairs.", !isDataSource), + }, + TAGS: tagsSchema(tagsSchemaOptions{isDataSource: isDataSource}), + COST_PER_INPUT_TOKEN: { + Type: schema.TypeFloat, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Cost per input token in USD.", !isDataSource), + }, + COST_PER_OUTPUT_TOKEN: { + Type: schema.TypeFloat, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Cost per output token in USD.", !isDataSource), + }, + VERSION: { + Type: schema.TypeInt, + Computed: true, + Description: "Version of the AI model config.", + }, + GLOBAL: { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the model is global.", + }, + } + + if !isDataSource { + schemaMap[TAGS].ForceNew = true + } + + if isDataSource { + return removeInvalidFieldsForDataSource(schemaMap) + } + + return schemaMap +} + +func aiModelConfigRead(ctx context.Context, d *schema.ResourceData, metaRaw interface{}, isDataSource bool) diag.Diagnostics { + client := metaRaw.(*Client) + + var diags diag.Diagnostics + + projectKey := d.Get(PROJECT_KEY).(string) + key := d.Get(KEY).(string) + + var modelConfig *ldapi.ModelConfig + var res *http.Response + var err error + err = client.withConcurrency(client.ctx, func() error { + modelConfig, res, err = client.ld.AIConfigsBetaApi.GetModelConfig(client.ctx, projectKey, key).Execute() + return err + }) + + if isStatusNotFound(res) && !isDataSource { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "AI model config not found", + Detail: fmt.Sprintf("[WARN] AI model config %q in project %q not found, removing from state", key, projectKey), + }) + d.SetId("") + return diags + } + if err != nil { + return diag.FromErr(err) + } + + _ = d.Set(KEY, modelConfig.Key) + _ = d.Set(NAME, modelConfig.Name) + _ = d.Set(ID, modelConfig.Id) + _ = d.Set(MODEL_PROVIDER, modelConfig.Provider) + _ = d.Set(ICON, modelConfig.Icon) + _ = d.Set(TAGS, modelConfig.Tags) + _ = d.Set(VERSION, modelConfig.Version) + _ = d.Set(GLOBAL, modelConfig.Global) + + if modelConfig.CostPerInputToken != nil { + _ = d.Set(COST_PER_INPUT_TOKEN, *modelConfig.CostPerInputToken) + } + if modelConfig.CostPerOutputToken != nil { + _ = d.Set(COST_PER_OUTPUT_TOKEN, *modelConfig.CostPerOutputToken) + } + + if modelConfig.Params != nil { + _ = d.Set(PARAMS, flattenParams(modelConfig.Params)) + } + if modelConfig.CustomParams != nil { + _ = d.Set(CUSTOM_PARAMS, flattenParams(modelConfig.CustomParams)) + } + + d.SetId(projectKey + "/" + key) + + return diags +} + +func aiModelConfigIdToKeys(id string) (projectKey string, modelConfigKey string, err error) { + if strings.Count(id, "/") != 1 { + return "", "", fmt.Errorf("found unexpected AI model config id format: %q expected format: 'project_key/model_config_key'", id) + } + parts := strings.SplitN(id, "/", 2) + projectKey, modelConfigKey = parts[0], parts[1] + return projectKey, modelConfigKey, nil +} + +func flattenParams(params map[string]interface{}) map[string]string { + result := make(map[string]string) + for k, v := range params { + if v != nil { + result[k] = fmt.Sprintf("%v", v) + } + } + return result +} + +func expandParams(params map[string]interface{}) map[string]interface{} { + if params == nil { + return nil + } + return params +} diff --git a/launchdarkly/data_source_launchdarkly_ai_model_config.go b/launchdarkly/data_source_launchdarkly_ai_model_config.go new file mode 100644 index 00000000..7333e1d0 --- /dev/null +++ b/launchdarkly/data_source_launchdarkly_ai_model_config.go @@ -0,0 +1,23 @@ +package launchdarkly + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceAIModelConfig() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceAIModelConfigRead, + Schema: baseAIModelConfigSchema(true), + + Description: `Provides a LaunchDarkly AI model config data source. + +This data source allows you to retrieve AI model config information from your LaunchDarkly organization.`, + } +} + +func dataSourceAIModelConfigRead(ctx context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + return aiModelConfigRead(ctx, d, metaRaw, true) +} diff --git a/launchdarkly/data_source_launchdarkly_feature_flag_environment_test.go b/launchdarkly/data_source_launchdarkly_feature_flag_environment_test.go index ceadc232..2f85f2f7 100644 --- a/launchdarkly/data_source_launchdarkly_feature_flag_environment_test.go +++ b/launchdarkly/data_source_launchdarkly_feature_flag_environment_test.go @@ -156,8 +156,11 @@ func TestAccDataSourceFeatureFlagEnvironment_exists(t *testing.T) { require.NoError(t, err) }() - thisConfig := flag.Environments[envKey] - otherConfig := flag.Environments["production"] + if flag.Environments == nil { + t.Fatal("flag.Environments is nil") + } + thisConfig := (*flag.Environments)[envKey] + otherConfig := (*flag.Environments)["production"] flagId := projectKey + "/" + flagKey resourceName := "data.launchdarkly_feature_flag_environment.test" @@ -283,8 +286,11 @@ func TestAccDataSourceFeatureFlagEnvironment_WithContextFields(t *testing.T) { require.NoError(t, err) }() - thisConfig := flag.Environments[envKey] - otherConfig := flag.Environments["production"] + if flag.Environments == nil { + t.Fatal("flag.Environments is nil") + } + thisConfig := (*flag.Environments)[envKey] + otherConfig := (*flag.Environments)["production"] flagId := projectKey + "/" + flagKey resourceName := "data.launchdarkly_feature_flag_environment.test" diff --git a/launchdarkly/feature_flag_environment_helper.go b/launchdarkly/feature_flag_environment_helper.go index 4886d834..3240af0e 100644 --- a/launchdarkly/feature_flag_environment_helper.go +++ b/launchdarkly/feature_flag_environment_helper.go @@ -119,7 +119,17 @@ func featureFlagEnvironmentRead(ctx context.Context, d *schema.ResourceData, raw return diag.Errorf("failed to get flag %q of project %q: %s", flagKey, projectKey, handleLdapiErr(err)) } - environment, ok := flag.Environments[envKey] + if flag.Environments == nil { + log.Printf("[WARN] flag %q has no environments, removing from state", flagKey) + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("[WARN] flag %q has no environments, removing from state", flagKey), + }) + d.SetId("") + return diags + } + + environment, ok := (*flag.Environments)[envKey] if !ok { log.Printf("[WARN] failed to find environment %q for flag %q, removing from state", envKey, flagKey) diags = append(diags, diag.Diagnostic{ diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index d9d8b486..23f68255 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -24,8 +24,11 @@ const ( CONFIRM_CHANGES = "confirm_changes" CONTEXT_KIND = "context_kind" CONTEXT_TARGETS = "context_targets" + COST_PER_INPUT_TOKEN = "cost_per_input_token" + COST_PER_OUTPUT_TOKEN = "cost_per_output_token" CREATION_DATE = "creation_date" CRITICAL = "critical" + CUSTOM_PARAMS = "custom_params" CUSTOM_PROPERTIES = "custom_properties" CUSTOM_ROLES = "custom_roles" CUSTOM_ROLE_KEYS = "custom_role_keys" @@ -53,6 +56,8 @@ const ( FLAG_ID = "flag_id" FLAG_KEY = "flag_key" FULL_KEY = "full_key" + GLOBAL = "global" + ICON = "icon" ID = "id" IGNORE_MISSING = "ignore_missing" INCLUDED = "included" @@ -64,6 +69,7 @@ const ( INTEGRATION_KEY = "integration_key" IS_ACTIVE = "is_active" IS_NUMERIC = "is_numeric" + IS_RESTRICTED = "is_restricted" KEY = "key" KIND = "kind" LAST_NAME = "last_name" @@ -73,6 +79,7 @@ const ( MEMBER_IDS = "member_ids" MIN_NUM_APPROVALS = "min_num_approvals" MOBILE_KEY = "mobile_key" + MODEL_CONFIG_KEY = "model_config_key" NAME = "name" NEGATE = "negate" NOT_ACTIONS = "not_actions" @@ -81,6 +88,7 @@ const ( ON = "on" ON_VARIATION = "on_variation" OP = "op" + PARAMS = "params" PATTERN = "pattern" PERCENTILE_VALUE = "percentile_value" POLICY = "policy" @@ -88,6 +96,7 @@ const ( PREREQUISITES = "prerequisites" PROJECT_KEY = "project_key" PROJECT_KEYS = "project_keys" + MODEL_PROVIDER = "model_provider" RANDOMIZATION_UNITS = "randomization_units" REQUIRED = "required" REQUIRED_APPROVAL_TAGS = "required_approval_tags" diff --git a/launchdarkly/provider.go b/launchdarkly/provider.go index e32f5454..92de55c4 100644 --- a/launchdarkly/provider.go +++ b/launchdarkly/provider.go @@ -77,6 +77,7 @@ func Provider() *schema.Provider { "launchdarkly_audit_log_subscription": resourceAuditLogSubscription(), "launchdarkly_relay_proxy_configuration": resourceRelayProxyConfig(), "launchdarkly_metric": resourceMetric(), + "launchdarkly_ai_model_config": resourceAIModelConfig(), }, DataSourcesMap: map[string]*schema.Resource{ "launchdarkly_team": dataSourceTeam(), @@ -92,6 +93,7 @@ func Provider() *schema.Provider { "launchdarkly_audit_log_subscription": dataSourceAuditLogSubscription(), "launchdarkly_relay_proxy_configuration": dataSourceRelayProxyConfig(), "launchdarkly_metric": dataSourceMetric(), + "launchdarkly_ai_model_config": dataSourceAIModelConfig(), }, ConfigureContextFunc: providerConfigure, } diff --git a/launchdarkly/resource_launchdarkly_ai_model_config.go b/launchdarkly/resource_launchdarkly_ai_model_config.go new file mode 100644 index 00000000..4e1f0cbe --- /dev/null +++ b/launchdarkly/resource_launchdarkly_ai_model_config.go @@ -0,0 +1,138 @@ +package launchdarkly + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + ldapi "github.com/launchdarkly/api-client-go/v17" +) + +func resourceAIModelConfig() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAIModelConfigCreate, + ReadContext: resourceAIModelConfigRead, + DeleteContext: resourceAIModelConfigDelete, + Schema: baseAIModelConfigSchema(false), + Importer: &schema.ResourceImporter{ + State: resourceAIModelConfigImport, + }, + + Description: `Provides a LaunchDarkly AI model config resource. + +This resource allows you to create and manage AI model configs within your LaunchDarkly organization. + +~> **Note:** AI model configs cannot be updated after creation. Any changes will force the destruction and recreation of the resource.`, + } +} + +func resourceAIModelConfigCreate(ctx context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + client := metaRaw.(*Client) + + var diags diag.Diagnostics + + projectKey := d.Get(PROJECT_KEY).(string) + + if exists, err := projectExists(projectKey, client); !exists { + if err != nil { + return diag.FromErr(err) + } + return diag.Errorf("cannot find project with key %q", projectKey) + } + + key := d.Get(KEY).(string) + name := d.Get(NAME).(string) + id := d.Get(ID).(string) + provider := d.Get(MODEL_PROVIDER).(string) + icon := d.Get(ICON).(string) + tags := stringsFromResourceData(d, TAGS) + + modelConfig := ldapi.ModelConfigPost{ + Key: key, + Name: name, + Id: id, + Provider: &provider, + Icon: &icon, + Tags: tags, + } + + if params, ok := d.GetOk(PARAMS); ok { + modelConfig.Params = expandParams(params.(map[string]interface{})) + } + + if customParams, ok := d.GetOk(CUSTOM_PARAMS); ok { + modelConfig.CustomParams = expandParams(customParams.(map[string]interface{})) + } + + if costPerInputToken, ok := d.GetOk(COST_PER_INPUT_TOKEN); ok { + cost := float64(costPerInputToken.(float64)) + modelConfig.CostPerInputToken = &cost + } + + if costPerOutputToken, ok := d.GetOk(COST_PER_OUTPUT_TOKEN); ok { + cost := float64(costPerOutputToken.(float64)) + modelConfig.CostPerOutputToken = &cost + } + + var err error + err = client.withConcurrency(client.ctx, func() error { + _, _, err = client.ld.AIConfigsBetaApi.PostModelConfig(client.ctx, projectKey).ModelConfigPost(modelConfig).Execute() + return err + }) + + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Error creating AI model config resource: %q", key), + Detail: fmt.Sprintf("Details: \n %q", handleLdapiErr(err)), + }) + return diags + } + + d.SetId(projectKey + "/" + key) + + return resourceAIModelConfigRead(ctx, d, metaRaw) +} + +func resourceAIModelConfigRead(ctx context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + return aiModelConfigRead(ctx, d, metaRaw, false) +} + +func resourceAIModelConfigDelete(ctx context.Context, d *schema.ResourceData, metaRaw interface{}) diag.Diagnostics { + client := metaRaw.(*Client) + var diags diag.Diagnostics + + projectKey := d.Get(PROJECT_KEY).(string) + key := d.Get(KEY).(string) + + var err error + err = client.withConcurrency(client.ctx, func() error { + _, err = client.ld.AIConfigsBetaApi.DeleteModelConfig(client.ctx, projectKey, key).Execute() + return err + }) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Error deleting AI model config resource %q from project %q", key, projectKey), + Detail: fmt.Sprintf("Details: \n %q", handleLdapiErr(err)), + }) + return diags + } + + return resourceAIModelConfigRead(ctx, d, metaRaw) +} + +func resourceAIModelConfigImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + id := d.Id() + + projectKey, modelConfigKey, err := aiModelConfigIdToKeys(id) + if err != nil { + return nil, err + } + _ = d.Set(PROJECT_KEY, projectKey) + _ = d.Set(KEY, modelConfigKey) + + return []*schema.ResourceData{d}, nil +} diff --git a/launchdarkly/resource_launchdarkly_feature_flag_environment_test.go b/launchdarkly/resource_launchdarkly_feature_flag_environment_test.go index 7d522b72..f0da23cf 100644 --- a/launchdarkly/resource_launchdarkly_feature_flag_environment_test.go +++ b/launchdarkly/resource_launchdarkly_feature_flag_environment_test.go @@ -1295,7 +1295,8 @@ func testAccCheckFeatureFlagEnvironmentDefaults(t *testing.T, projectKey, flagKe require.NoError(t, err) flag, _, err := client.ld.FeatureFlagsApi.GetFeatureFlag(client.ctx, projectKey, flagKey).Execute() require.NoError(t, err) - envConfig := flag.Environments["test"] + require.NotNil(t, flag.Environments, "flag.Environments should not be nil") + envConfig := (*flag.Environments)["test"] require.Equal(t, int32(0), *envConfig.OffVariation) return nil } diff --git a/launchdarkly/resource_launchdarkly_metric.go b/launchdarkly/resource_launchdarkly_metric.go index eee882ee..cf259e05 100644 --- a/launchdarkly/resource_launchdarkly_metric.go +++ b/launchdarkly/resource_launchdarkly_metric.go @@ -191,7 +191,6 @@ func resourceMetricCreate(ctx context.Context, d *schema.ResourceData, metaRaw i kind := d.Get(KIND).(string) description := d.Get(DESCRIPTION).(string) tags := stringsFromResourceData(d, TAGS) - isActive := d.Get(IS_ACTIVE).(bool) isNumeric := d.Get(IS_NUMERIC).(bool) urls := metricUrlsFromResourceData(d) randomizationUnits := stringsFromResourceData(d, RANDOMIZATION_UNITS) @@ -210,7 +209,6 @@ func resourceMetricCreate(ctx context.Context, d *schema.ResourceData, metaRaw i Description: &description, Tags: tags, Kind: kind, - IsActive: &isActive, IsNumeric: &isNumeric, Selector: &selector, Urls: urls,