Skip to content
Open
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
194 changes: 194 additions & 0 deletions launchdarkly/ai_model_config_helper.go
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you actually want to force new for resources that change their name? this will trigger a delete and then a recreation of a totally new resource with the same properties

Description: addForceNewDescription("The human-friendly name for the AI model config.", !isDataSource),
},
ID: {
Type: schema.TypeString,
Required: !isDataSource,
Computed: isDataSource,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does the user assign this or does LD? if LD assigns this it should always be computed and the force new is moot

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto force new

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto force 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: {
Type: schema.TypeFloat,
Optional: !isDataSource,
Computed: isDataSource,
ForceNew: !isDataSource,
Copy link
Member

@sloloris sloloris Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay i see devin just thinks everything should force new. i'm assuming this has something to do with the no update thing, but i still think someone should look at this more closely

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the detailed review! I want to address the ForceNew concerns and explain my reasoning.

The Core Issue: No PATCH Endpoint

I've investigated the API client v17.2.0 and the available methods for AI Model Configs are:

  • PostModelConfig (create)
  • GetModelConfig (read)
  • DeleteModelConfig (delete)
  • ListModelConfigs (list)

There is no PatchModelConfig method in the API client. Without an update endpoint, I cannot implement Terraform's UpdateContext function. If I remove ForceNew from user-settable fields, Terraform would plan an update but fail at apply time because there's no API to call.

Is there an undocumented or planned update endpoint I should be using instead? If so, I'm happy to implement proper updates and remove ForceNew where appropriate.

Addressing Your Specific Comments:

  1. params/customParams as objects: You're absolutely right! The OpenAPI spec shows these as type: "object", not strings. I'll change them to schema.TypeMap to provide a better UX.

  2. ID field: According to the OpenAPI spec, id is required in ModelConfigPost, so it's user-provided (not LD-assigned). I'll keep it as Required: true with ForceNew: true.

  3. GLOBAL field: You're correct - global is NOT in ModelConfigPost, so it's server-assigned. I'll change it to Computed: true only and remove ForceNew.

  4. ForceNew on other fields: Fields like name, provider, icon, costPerInputToken, costPerOutputToken, etc. are all user-provided in ModelConfigPost. Without a PATCH endpoint, these need ForceNew: true to maintain correct Terraform semantics. Otherwise, users would see planned updates that fail at apply time.

My Plan:

  • Change params/customParams to TypeMap objects
  • Fix GLOBAL to be Computed-only
  • Keep ForceNew on user-settable fields (unless you can point me to an update endpoint)
  • Add Beta API header if needed

Please let me know if there's an update endpoint I'm missing, or if you'd prefer a different approach!

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this user-defined or does LD auto-assign the value? if the former it should not be computed

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
}
23 changes: 23 additions & 0 deletions launchdarkly/data_source_launchdarkly_ai_model_config.go
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion launchdarkly/feature_flag_environment_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
9 changes: 9 additions & 0 deletions launchdarkly/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -81,13 +88,15 @@ const (
ON = "on"
ON_VARIATION = "on_variation"
OP = "op"
PARAMS = "params"
PATTERN = "pattern"
PERCENTILE_VALUE = "percentile_value"
POLICY = "policy"
POLICY_STATEMENTS = "policy_statements"
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"
Expand Down
2 changes: 2 additions & 0 deletions launchdarkly/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
}
Expand Down
Loading
Loading