From a101c75252c055d61729b5248a55dd4c542d81ec Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:52:04 +0000 Subject: [PATCH 1/5] Add AI Model Config resource and data source - Upgrade api-client-go from v17.1.0 to v17.2.0 for AI Model Config support - Add resource_launchdarkly_ai_model_config with Create, Read, Delete operations - Add data_source_launchdarkly_ai_model_config for read-only access - Add ai_model_config_helper with schema and read functions - Add JSON helper functions for params and customParams fields - Register both resource and data source in provider - Add schema constants for AI Model Config fields Note: AI Model Configs do not support updates via PATCH API, so all fields are marked as ForceNew to force recreation on any changes. Co-Authored-By: traci@launchdarkly.com --- go.mod | 2 +- go.sum | 2 + launchdarkly/ai_model_config_helper.go | 185 ++++++++++++++++++ ...ata_source_launchdarkly_ai_model_config.go | 23 +++ launchdarkly/helper.go | 32 +++ launchdarkly/keys.go | 9 + launchdarkly/provider.go | 2 + .../resource_launchdarkly_ai_model_config.go | 147 ++++++++++++++ 8 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 launchdarkly/ai_model_config_helper.go create mode 100644 launchdarkly/data_source_launchdarkly_ai_model_config.go create mode 100644 launchdarkly/resource_launchdarkly_ai_model_config.go diff --git a/go.mod b/go.mod index ecd528bb..7810d572 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,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.16.0 ) diff --git a/go.sum b/go.sum index dd7b82d7..0378642e 100644 --- a/go.sum +++ b/go.sum @@ -542,6 +542,8 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+ 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..8416bb03 --- /dev/null +++ b/launchdarkly/ai_model_config_helper.go @@ -0,0 +1,185 @@ +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), + }, + 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.TypeString, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Model parameters as a JSON string.", !isDataSource), + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return jsonEqual(old, new) + }, + }, + CUSTOM_PARAMS: { + Type: schema.TypeString, + Optional: !isDataSource, + Computed: isDataSource, + ForceNew: !isDataSource, + Description: addForceNewDescription("Custom model parameters as a JSON string.", !isDataSource), + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return jsonEqual(old, new) + }, + }, + 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 { + 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(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 { + paramsJSON, err := jsonMarshal(modelConfig.Params) + if err != nil { + return diag.FromErr(err) + } + _ = d.Set(PARAMS, paramsJSON) + } + if modelConfig.CustomParams != nil { + customParamsJSON, err := jsonMarshal(modelConfig.CustomParams) + if err != nil { + return diag.FromErr(err) + } + _ = d.Set(CUSTOM_PARAMS, customParamsJSON) + } + + 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 +} 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/helper.go b/launchdarkly/helper.go index 933bae67..194925f2 100644 --- a/launchdarkly/helper.go +++ b/launchdarkly/helper.go @@ -1,10 +1,12 @@ package launchdarkly import ( + "encoding/json" "fmt" "math/rand" "net" "net/http" + "reflect" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -157,3 +159,33 @@ func oxfordCommaJoin(str []string) string { } return output } + +func jsonMarshal(v interface{}) (string, error) { + if v == nil { + return "{}", nil + } + bytes, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func jsonEqual(a, b string) bool { + if a == "" && b == "" { + return true + } + if a == "" || b == "" { + return false + } + + var aMap, bMap interface{} + if err := json.Unmarshal([]byte(a), &aMap); err != nil { + return false + } + if err := json.Unmarshal([]byte(b), &bMap); err != nil { + return false + } + + return reflect.DeepEqual(aMap, bMap) +} diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index d9d8b486..ea46a840 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" + PROVIDER = "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..2849e776 --- /dev/null +++ b/launchdarkly/resource_launchdarkly_ai_model_config.go @@ -0,0 +1,147 @@ +package launchdarkly + +import ( + "context" + "encoding/json" + "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(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 paramsStr, ok := d.GetOk(PARAMS); ok { + var params map[string]interface{} + if err := json.Unmarshal([]byte(paramsStr.(string)), ¶ms); err != nil { + return diag.FromErr(fmt.Errorf("failed to parse params JSON: %w", err)) + } + modelConfig.Params = params + } + + if customParamsStr, ok := d.GetOk(CUSTOM_PARAMS); ok { + var customParams map[string]interface{} + if err := json.Unmarshal([]byte(customParamsStr.(string)), &customParams); err != nil { + return diag.FromErr(fmt.Errorf("failed to parse custom_params JSON: %w", err)) + } + modelConfig.CustomParams = customParams + } + + 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 +} From d3e0328a557e3768a4d13213a955dfb530de7207 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:20:26 +0000 Subject: [PATCH 2/5] Address PR feedback and fix v17.2.0 compatibility issues - Change params and customParams from JSON strings to TypeMap objects per reviewer feedback - Fix GLOBAL field to be Computed-only (already correct, no changes needed) - Fix v17.2.0 compatibility: dereference pointer for flag.Environments in feature_flag_environment_helper.go - Fix v17.2.0 compatibility: remove IsActive field from MetricPost in resource_launchdarkly_metric.go - Remove unused isActive variable from metric create function Co-Authored-By: traci@launchdarkly.com --- launchdarkly/ai_model_config_helper.go | 45 ++++++++++--------- .../feature_flag_environment_helper.go | 12 ++++- .../resource_launchdarkly_ai_model_config.go | 17 ++----- launchdarkly/resource_launchdarkly_metric.go | 2 - 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/launchdarkly/ai_model_config_helper.go b/launchdarkly/ai_model_config_helper.go index 8416bb03..8a028d26 100644 --- a/launchdarkly/ai_model_config_helper.go +++ b/launchdarkly/ai_model_config_helper.go @@ -57,24 +57,20 @@ func baseAIModelConfigSchema(isDataSource bool) map[string]*schema.Schema { Description: addForceNewDescription("Icon for the model.", !isDataSource), }, PARAMS: { - Type: schema.TypeString, + Type: schema.TypeMap, Optional: !isDataSource, Computed: isDataSource, ForceNew: !isDataSource, - Description: addForceNewDescription("Model parameters as a JSON string.", !isDataSource), - DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { - return jsonEqual(old, new) - }, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: addForceNewDescription("Model parameters as a map of key-value pairs.", !isDataSource), }, CUSTOM_PARAMS: { - Type: schema.TypeString, + Type: schema.TypeMap, Optional: !isDataSource, Computed: isDataSource, ForceNew: !isDataSource, - Description: addForceNewDescription("Custom model parameters as a JSON string.", !isDataSource), - DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { - return jsonEqual(old, new) - }, + 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: { @@ -156,18 +152,10 @@ func aiModelConfigRead(ctx context.Context, d *schema.ResourceData, metaRaw inte } if modelConfig.Params != nil { - paramsJSON, err := jsonMarshal(modelConfig.Params) - if err != nil { - return diag.FromErr(err) - } - _ = d.Set(PARAMS, paramsJSON) + _ = d.Set(PARAMS, flattenParams(modelConfig.Params)) } if modelConfig.CustomParams != nil { - customParamsJSON, err := jsonMarshal(modelConfig.CustomParams) - if err != nil { - return diag.FromErr(err) - } - _ = d.Set(CUSTOM_PARAMS, customParamsJSON) + _ = d.Set(CUSTOM_PARAMS, flattenParams(modelConfig.CustomParams)) } d.SetId(projectKey + "/" + key) @@ -183,3 +171,20 @@ func aiModelConfigIdToKeys(id string) (projectKey string, modelConfigKey string, 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/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/resource_launchdarkly_ai_model_config.go b/launchdarkly/resource_launchdarkly_ai_model_config.go index 2849e776..a242b68e 100644 --- a/launchdarkly/resource_launchdarkly_ai_model_config.go +++ b/launchdarkly/resource_launchdarkly_ai_model_config.go @@ -2,7 +2,6 @@ package launchdarkly import ( "context" - "encoding/json" "fmt" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -59,20 +58,12 @@ func resourceAIModelConfigCreate(ctx context.Context, d *schema.ResourceData, me Tags: tags, } - if paramsStr, ok := d.GetOk(PARAMS); ok { - var params map[string]interface{} - if err := json.Unmarshal([]byte(paramsStr.(string)), ¶ms); err != nil { - return diag.FromErr(fmt.Errorf("failed to parse params JSON: %w", err)) - } - modelConfig.Params = params + if params, ok := d.GetOk(PARAMS); ok { + modelConfig.Params = expandParams(params.(map[string]interface{})) } - if customParamsStr, ok := d.GetOk(CUSTOM_PARAMS); ok { - var customParams map[string]interface{} - if err := json.Unmarshal([]byte(customParamsStr.(string)), &customParams); err != nil { - return diag.FromErr(fmt.Errorf("failed to parse custom_params JSON: %w", err)) - } - modelConfig.CustomParams = customParams + 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 { 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, From f34d9d180bd47db3ced08245e2ab29222bba384c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:30:21 +0000 Subject: [PATCH 3/5] test: update feature flag tests for api-client-go v17.2.0 pointer-to-map change The v17.2.0 API client changed flag.Environments from map[string]FeatureFlagConfig to *map[string]FeatureFlagConfig (pointer to map). Updated test files to add nil checks and dereference the pointer before indexing, matching the pattern used in production code (feature_flag_environment_helper.go). Co-Authored-By: traci@launchdarkly.com --- ...e_launchdarkly_feature_flag_environment_test.go | 14 ++++++++++---- ...e_launchdarkly_feature_flag_environment_test.go | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) 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/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 } From a9275374e25531a0012e5291a985c96301426591 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:34:27 +0000 Subject: [PATCH 4/5] chore: remove unused JSON helper functions Removed jsonMarshal and jsonEqual functions that were no longer needed after changing params and customParams from JSON strings to TypeMap objects. Also removed now-unused encoding/json and reflect imports. Co-Authored-By: traci@launchdarkly.com --- launchdarkly/helper.go | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/launchdarkly/helper.go b/launchdarkly/helper.go index 194925f2..933bae67 100644 --- a/launchdarkly/helper.go +++ b/launchdarkly/helper.go @@ -1,12 +1,10 @@ package launchdarkly import ( - "encoding/json" "fmt" "math/rand" "net" "net/http" - "reflect" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -159,33 +157,3 @@ func oxfordCommaJoin(str []string) string { } return output } - -func jsonMarshal(v interface{}) (string, error) { - if v == nil { - return "{}", nil - } - bytes, err := json.Marshal(v) - if err != nil { - return "", err - } - return string(bytes), nil -} - -func jsonEqual(a, b string) bool { - if a == "" && b == "" { - return true - } - if a == "" || b == "" { - return false - } - - var aMap, bMap interface{} - if err := json.Unmarshal([]byte(a), &aMap); err != nil { - return false - } - if err := json.Unmarshal([]byte(b), &bMap); err != nil { - return false - } - - return reflect.DeepEqual(aMap, bMap) -} From 36b30fa5f49ed925560f1ec7875ddcd6a23d360e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:39:56 +0000 Subject: [PATCH 5/5] fix: resolve Terraform validation errors in AI Model Config - Rename 'provider' field to 'model_provider' to avoid Terraform reserved name conflict - Add ForceNew to tags field since there is no Update endpoint - Both resource and data source now pass InternalValidate checks Co-Authored-By: traci@launchdarkly.com --- launchdarkly/ai_model_config_helper.go | 8 ++++++-- launchdarkly/keys.go | 2 +- launchdarkly/resource_launchdarkly_ai_model_config.go | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/launchdarkly/ai_model_config_helper.go b/launchdarkly/ai_model_config_helper.go index 8a028d26..b71d1b6e 100644 --- a/launchdarkly/ai_model_config_helper.go +++ b/launchdarkly/ai_model_config_helper.go @@ -42,7 +42,7 @@ func baseAIModelConfigSchema(isDataSource bool) map[string]*schema.Schema { ForceNew: !isDataSource, Description: addForceNewDescription("Identifier for the model, for use with third party providers.", !isDataSource), }, - PROVIDER: { + MODEL_PROVIDER: { Type: schema.TypeString, Optional: !isDataSource, Computed: isDataSource, @@ -99,6 +99,10 @@ func baseAIModelConfigSchema(isDataSource bool) map[string]*schema.Schema { }, } + if !isDataSource { + schemaMap[TAGS].ForceNew = true + } + if isDataSource { return removeInvalidFieldsForDataSource(schemaMap) } @@ -138,7 +142,7 @@ func aiModelConfigRead(ctx context.Context, d *schema.ResourceData, metaRaw inte _ = d.Set(KEY, modelConfig.Key) _ = d.Set(NAME, modelConfig.Name) _ = d.Set(ID, modelConfig.Id) - _ = d.Set(PROVIDER, modelConfig.Provider) + _ = d.Set(MODEL_PROVIDER, modelConfig.Provider) _ = d.Set(ICON, modelConfig.Icon) _ = d.Set(TAGS, modelConfig.Tags) _ = d.Set(VERSION, modelConfig.Version) diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index ea46a840..23f68255 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -96,7 +96,7 @@ const ( PREREQUISITES = "prerequisites" PROJECT_KEY = "project_key" PROJECT_KEYS = "project_keys" - PROVIDER = "provider" + MODEL_PROVIDER = "model_provider" RANDOMIZATION_UNITS = "randomization_units" REQUIRED = "required" REQUIRED_APPROVAL_TAGS = "required_approval_tags" diff --git a/launchdarkly/resource_launchdarkly_ai_model_config.go b/launchdarkly/resource_launchdarkly_ai_model_config.go index a242b68e..4e1f0cbe 100644 --- a/launchdarkly/resource_launchdarkly_ai_model_config.go +++ b/launchdarkly/resource_launchdarkly_ai_model_config.go @@ -45,7 +45,7 @@ func resourceAIModelConfigCreate(ctx context.Context, d *schema.ResourceData, me key := d.Get(KEY).(string) name := d.Get(NAME).(string) id := d.Get(ID).(string) - provider := d.Get(PROVIDER).(string) + provider := d.Get(MODEL_PROVIDER).(string) icon := d.Get(ICON).(string) tags := stringsFromResourceData(d, TAGS)