diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md new file mode 100644 index 0000000..602ea06 --- /dev/null +++ b/docs/resources/agents_model.md @@ -0,0 +1,97 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_agents_model Resource - terraform-provider-coderd" +subcategory: "" +description: |- + ~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. + Configures an admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see coderd_ai_provider) along with context, compression, and optional JSON tuning settings. +--- + +# coderd_agents_model (Resource) + +~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments. + +Configures an admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings. + +## Example Usage + +```terraform +variable "anthropic_api_key" { + type = string + sensitive = true +} + +resource "coderd_ai_provider" "anthropic" { + type = "anthropic" + name = "anthropic" + base_url = "https://api.anthropic.com" + + api_key_wo = var.anthropic_api_key + api_key_wo_version = 1 +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = coderd_ai_provider.anthropic.id + model = "claude-3-5-sonnet-20241022" + display_name = "Claude 3.5 Sonnet" + enabled = true + context_limit = 200000 + + model_config = jsonencode({ + max_output_tokens = 8192 + temperature = 0.7 + cost = { + input_price_per_million_tokens = "3" + output_price_per_million_tokens = "15" + } + provider_options = { + anthropic = { + effort = "high" + thinking = { budget_tokens = 4096 } + } + } + }) +} +``` + + +## Schema + +### Required + +- `ai_provider_id` (String) AI provider ID that backs this model. Usually this is `coderd_ai_provider..id`. Updating it re-derives the read-only `provider_type` from the referenced provider. +- `context_limit` (Number) Maximum context window for this model. Must be greater than zero. +- `model` (String) Model identifier to use with the referenced provider. + +### Optional + +- `compression_threshold` (Number) Percentage of the context window at which Coder should compact chat context. Defaults to 70 and must be between 0 and 100. +- `display_name` (String) Display name shown in Coder. +- `enabled` (Boolean) Whether this model configuration is enabled. Defaults to true. +- `model_config` (String) Optional JSON blob of per-call tuning for the model, such as `max_output_tokens`, `temperature`, `top_p`, `cost`, and `provider_options`. See the field reference (including per-provider `provider_options`) at https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig. + +### Read-Only + +- `created_at` (Number) Creation timestamp as Unix seconds. +- `id` (String) Agents model configuration ID. +- `provider_type` (String) Provider type derived by Coder from `ai_provider_id`, for example `openai`, `anthropic`, or `bedrock`. +- `updated_at` (Number) Last update timestamp as Unix seconds. + +## Import + +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +# The ID supplied must be the Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet +``` +Alternatively, in Terraform v1.5.0 and later, an [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used: + +```terraform +import { + to = coderd_agents_model.sonnet + id = "" +} +``` diff --git a/examples/resources/coderd_agents_model/import.sh b/examples/resources/coderd_agents_model/import.sh new file mode 100644 index 0000000..77d8614 --- /dev/null +++ b/examples/resources/coderd_agents_model/import.sh @@ -0,0 +1,10 @@ +# The ID supplied must be the Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet +``` +Alternatively, in Terraform v1.5.0 and later, an [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used: + +```terraform +import { + to = coderd_agents_model.sonnet + id = "" +} diff --git a/examples/resources/coderd_agents_model/resource.tf b/examples/resources/coderd_agents_model/resource.tf new file mode 100644 index 0000000..62eed4c --- /dev/null +++ b/examples/resources/coderd_agents_model/resource.tf @@ -0,0 +1,36 @@ +variable "anthropic_api_key" { + type = string + sensitive = true +} + +resource "coderd_ai_provider" "anthropic" { + type = "anthropic" + name = "anthropic" + base_url = "https://api.anthropic.com" + + api_key_wo = var.anthropic_api_key + api_key_wo_version = 1 +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = coderd_ai_provider.anthropic.id + model = "claude-3-5-sonnet-20241022" + display_name = "Claude 3.5 Sonnet" + enabled = true + context_limit = 200000 + + model_config = jsonencode({ + max_output_tokens = 8192 + temperature = 0.7 + cost = { + input_price_per_million_tokens = "3" + output_price_per_million_tokens = "15" + } + provider_options = { + anthropic = { + effort = "high" + thinking = { budget_tokens = 4096 } + } + } + }) +} diff --git a/go.mod b/go.mod index 0c882cf..b07f679 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,11 @@ require ( github.com/coder/websocket v1.8.15 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.7.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/terraform-plugin-docs v0.25.0 github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 github.com/hashicorp/terraform-plugin-log v0.10.0 @@ -85,7 +87,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect diff --git a/go.sum b/go.sum index 4f37ab7..e6073e4 100644 --- a/go.sum +++ b/go.sum @@ -263,6 +263,8 @@ github.com/hashicorp/terraform-plugin-docs v0.25.0 h1:qHs1V257NxVe8tv6HS4UQfNqja github.com/hashicorp/terraform-plugin-docs v0.25.0/go.mod h1:MQggCmY8zgP7R7E/cC0b0cmTvA9hSj3ZKyrrsDjRbLo= github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E= github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc= github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= diff --git a/integration/agents-model-test/main.tf b/integration/agents-model-test/main.tf new file mode 100644 index 0000000..5cdee10 --- /dev/null +++ b/integration/agents-model-test/main.tf @@ -0,0 +1,119 @@ +terraform { + required_providers { + coderd = { + source = "coder/coderd" + version = ">=0.0.0" + } + } +} + +resource "coderd_ai_provider" "anthropic" { + type = "anthropic" + name = "agents-anthropic" + display_name = "Anthropic" + base_url = "https://api.anthropic.com/" + + api_key_wo = "sk-ant-api03-integration-test" + api_key_wo_version = 1 +} + +resource "coderd_ai_provider" "openai" { + type = "openai" + name = "agents-openai" + display_name = "OpenAI" + base_url = "https://api.openai.com/v1" + + api_key_wo = "sk-integration-test-openai" + api_key_wo_version = 1 +} + +resource "coderd_agents_model" "claude_opus" { + ai_provider_id = coderd_ai_provider.anthropic.id + model = "claude-opus-4-8" + display_name = "Claude Opus 4.8" + context_limit = 1000000 + compression_threshold = 42 + + model_config = jsonencode({ + max_output_tokens = 128000 + cost = { + input_price_per_million_tokens = "5" + output_price_per_million_tokens = "25" + cache_read_price_per_million_tokens = "0.5" + cache_write_price_per_million_tokens = "6.25" + } + provider_options = { + anthropic = { + send_reasoning = true + effort = "high" + } + } + }) +} + +resource "coderd_agents_model" "claude_sonnet" { + ai_provider_id = coderd_ai_provider.anthropic.id + model = "claude-sonnet-4-6" + display_name = "Claude Sonnet 4.6" + context_limit = 200000 + compression_threshold = 70 + + model_config = jsonencode({ + cost = { + input_price_per_million_tokens = "3" + output_price_per_million_tokens = "15" + } + provider_options = { + anthropic = { + send_reasoning = true + effort = "max" + web_search_enabled = true + thinking = { + budget_tokens = 16000 + } + } + } + }) +} + +resource "coderd_agents_model" "gpt_xhigh" { + ai_provider_id = coderd_ai_provider.openai.id + model = "gpt-5.5" + display_name = "GPT-5.5" + context_limit = 272000 + compression_threshold = 70 + + model_config = jsonencode({ + cost = { + input_price_per_million_tokens = "2.5" + output_price_per_million_tokens = "15" + cache_read_price_per_million_tokens = "0.25" + } + provider_options = { + openai = { + parallel_tool_calls = false + reasoning_effort = "xhigh" + reasoning_summary = "detailed" + text_verbosity = "high" + web_search_enabled = true + search_context_size = "medium" + } + } + }) +} + +resource "coderd_agents_model" "gpt_mini" { + ai_provider_id = coderd_ai_provider.openai.id + model = "gpt-5.4-mini" + display_name = "GPT-5.4 Mini" + context_limit = 400000 + compression_threshold = 70 + + model_config = jsonencode({ + provider_options = { + openai = { + reasoning_effort = "medium" + } + } + }) +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 26cf683..a4521e1 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -3,6 +3,7 @@ package integration import ( "bytes" "context" + "encoding/json" "fmt" "os" "os/exec" @@ -12,6 +13,7 @@ import ( "time" "github.com/coder/coder/v2/codersdk" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -194,6 +196,94 @@ func TestIntegration(t *testing.T) { require.Equal(t, user.ID, acl.Users[0].ID) }, }, + { + name: "agents-model-test", + preF: func(t testing.TB, c *codersdk.Client) {}, + assertF: func(t testing.TB, c *codersdk.Client) { + providers, err := c.AIProviders(ctx) + require.NoError(t, err) + require.Len(t, providers, 2) + + exp := codersdk.NewExperimentalClient(c) + configs, err := exp.ListChatModelConfigs(ctx) + require.NoError(t, err) + + // model -> {provider type, expected model_config JSON} (mirrors main.tf). + want := map[string]struct{ provider, config string }{ + "claude-opus-4-8": {"anthropic", `{ + "max_output_tokens": 128000, + "cost": { + "input_price_per_million_tokens": "5", + "output_price_per_million_tokens": "25", + "cache_read_price_per_million_tokens": "0.5", + "cache_write_price_per_million_tokens": "6.25" + }, + "provider_options": { + "anthropic": { + "send_reasoning": true, + "effort": "high" + } + } + }`}, + "claude-sonnet-4-6": {"anthropic", `{ + "cost": { + "input_price_per_million_tokens": "3", + "output_price_per_million_tokens": "15" + }, + "provider_options": { + "anthropic": { + "send_reasoning": true, + "effort": "max", + "web_search_enabled": true, + "thinking": { + "budget_tokens": 16000 + } + } + } + }`}, + "gpt-5.5": {"openai", `{ + "cost": { + "input_price_per_million_tokens": "2.5", + "output_price_per_million_tokens": "15", + "cache_read_price_per_million_tokens": "0.25" + }, + "provider_options": { + "openai": { + "parallel_tool_calls": false, + "reasoning_effort": "xhigh", + "reasoning_summary": "detailed", + "text_verbosity": "high", + "web_search_enabled": true, + "search_context_size": "medium" + } + } + }`}, + "gpt-5.4-mini": {"openai", `{ + "provider_options": { + "openai": { + "reasoning_effort": "medium" + } + } + }`}, + } + require.Len(t, configs, len(want)) + + for _, m := range configs { + w, ok := want[m.Model] + require.True(t, ok, "unexpected model %s", m.Model) + assert.Equal(t, w.provider, m.Provider) + require.NotNil(t, m.ModelConfig) + got, err := json.Marshal(m.ModelConfig) + require.NoError(t, err) + var wantConfig, gotConfig any + require.NoError(t, json.Unmarshal([]byte(w.config), &wantConfig)) + require.NoError(t, json.Unmarshal(got, &gotConfig)) + if diff := cmp.Diff(wantConfig, gotConfig); diff != "" { + t.Errorf("model_config for %s mismatch (-want +got):\n%s", m.Model, diff) + } + } + }, + }, } { t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go new file mode 100644 index 0000000..b3f479f --- /dev/null +++ b/internal/provider/agents_model_config.go @@ -0,0 +1,234 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/coder/coder/v2/codersdk" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// agentsModelConfigType canonicalizes model_config through codersdk.ChatModelCallConfig +// so the value the user writes and the value Coder stores back compare equal. +type agentsModelConfigType struct { + jsontypes.NormalizedType +} + +var _ basetypes.StringTypable = agentsModelConfigType{} + +// String implements basetypes.StringTypable. +func (t agentsModelConfigType) String() string { + return "agentsModelConfigType" +} + +// ValueType implements basetypes.StringTypable. +func (t agentsModelConfigType) ValueType(ctx context.Context) attr.Value { + return agentsModelConfigValue{} +} + +// Equal implements basetypes.StringTypable. +func (t agentsModelConfigType) Equal(o attr.Type) bool { + if o, ok := o.(agentsModelConfigType); ok { + return t.NormalizedType.Equal(o.NormalizedType) + } + return false +} + +// ValueFromString implements basetypes.StringTypable. +func (t agentsModelConfigType) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + return agentsModelConfigValue{Normalized: jsontypes.Normalized{StringValue: in}}, nil +} + +// ValueFromTerraform implements basetypes.StringTypable. +func (t agentsModelConfigType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.NormalizedType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + normalized, ok := attrValue.(jsontypes.Normalized) + if !ok { + return nil, fmt.Errorf("unexpected type %T, expected jsontypes.Normalized", attrValue) + } + return agentsModelConfigValue{Normalized: normalized}, nil +} + +type agentsModelConfigValue struct { + jsontypes.Normalized +} + +var ( + _ basetypes.StringValuableWithSemanticEquals = agentsModelConfigValue{} + _ xattr.ValidateableAttribute = agentsModelConfigValue{} +) + +func newAgentsModelConfigNull() agentsModelConfigValue { + return agentsModelConfigValue{Normalized: jsontypes.NewNormalizedNull()} +} + +func newAgentsModelConfigValue(value string) agentsModelConfigValue { + return agentsModelConfigValue{Normalized: jsontypes.NewNormalizedValue(value)} +} + +// Type implements basetypes.StringValuable. +func (v agentsModelConfigValue) Type(context.Context) attr.Type { + return agentsModelConfigType{} +} + +// Equal implements basetypes.StringValuable. +func (v agentsModelConfigValue) Equal(o attr.Value) bool { + if o, ok := o.(agentsModelConfigValue); ok { + return v.Normalized.Equal(o.Normalized) + } + return false +} + +// StringSemanticEquals treats two model_config docs as equal when they decode to +// the same struct; falls back to JSON comparison if either fails to decode. +func (v agentsModelConfigValue) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + newValue, ok := newValuable.(agentsModelConfigValue) + if !ok { + diags.AddError( + "Semantic Equality Check Error", + fmt.Sprintf("Expected value type %T but got %T. Please report this to the provider developers.", v, newValuable), + ) + return false, diags + } + + current, err := agentsModelConfigCanonicalJSON(v.ValueString()) + if err != nil { + return v.Normalized.StringSemanticEquals(ctx, newValue.Normalized) + } + proposed, err := agentsModelConfigCanonicalJSON(newValue.ValueString()) + if err != nil { + return v.Normalized.StringSemanticEquals(ctx, newValue.Normalized) + } + + return current == proposed, diags +} + +// agentsModelConfigCanonicalJSON round-trips a model_config document through the +// SDK type so equivalent encodings compare equal. This mirrors the encoding Coder +// applies when it stores and returns the value. +func agentsModelConfigCanonicalJSON(raw string) (string, error) { + var config codersdk.ChatModelCallConfig + if err := json.Unmarshal([]byte(raw), &config); err != nil { + return "", err + } + encoded, err := json.Marshal(config) + if err != nil { + return "", err + } + return string(encoded), nil +} + +// agentsModelConfigSortedJSON re-encodes a JSON document with object keys sorted +// alphabetically (recursively) and compact spacing, matching Terraform's +// jsonencode output. Numbers are preserved verbatim via json.Number, so the only +// change is key order. Coder stores model_config in the SDK struct's field order, +// which is not alphabetical; without this the byte string in state never matches +// the user's jsonencode config, and the framework's raw-byte plan guard +// (server_planresourcechange.go: PlannedState.Raw.Equal(PriorState.Raw)) then +// marks the computed updated_at attribute unknown on every plan after import. +func agentsModelConfigSortedJSON(raw []byte) (string, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return "", err + } + encoded, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(encoded), nil +} + +// agentsModelConfigUseStateIfSemanticallyEqual keeps the prior state value when +// the configured model_config canonicalizes to the same JSON. The plugin +// framework only runs StringSemanticEquals against state during refresh/apply, +// never against the (jsonencode-sorted) config during plan, so without this a +// key-order-only difference between state and config yields a perpetual no-op +// diff. This surfaces after `terraform import` (Read stores Coder's struct-order +// JSON, which the alphabetical jsonencode config never matches byte-for-byte). +type agentsModelConfigUseStateIfSemanticallyEqual struct{} + +var _ planmodifier.String = agentsModelConfigUseStateIfSemanticallyEqual{} + +func (agentsModelConfigUseStateIfSemanticallyEqual) Description(_ context.Context) string { + return "Keeps the prior model_config when the configured value is semantically equal." +} + +func (m agentsModelConfigUseStateIfSemanticallyEqual) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (agentsModelConfigUseStateIfSemanticallyEqual) PlanModifyString(_ context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + if req.StateValue.IsNull() || req.StateValue.IsUnknown() { + return + } + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + stateCanon, err := agentsModelConfigCanonicalJSON(req.StateValue.ValueString()) + if err != nil { + return + } + configCanon, err := agentsModelConfigCanonicalJSON(req.ConfigValue.ValueString()) + if err != nil { + return + } + if stateCanon == configCanon { + resp.PlanValue = req.StateValue + } +} + +// agentsModelConfigNotEmptyValidator rejects an empty model_config (e.g. jsonencode({})): +// Coder collapses it to null, which would trip Terraform's post-apply consistency check. +type agentsModelConfigNotEmptyValidator struct{} + +var _ validator.String = agentsModelConfigNotEmptyValidator{} + +func (v agentsModelConfigNotEmptyValidator) Description(_ context.Context) string { + return "model_config must contain at least one setting; omit the attribute entirely to use Coder's defaults." +} + +func (v agentsModelConfigNotEmptyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + // Invalid JSON is left for the custom type's ValidateAttribute to report. + canonical, err := agentsModelConfigCanonicalJSON(req.ConfigValue.ValueString()) + if err != nil { + // Report valid JSON that can't decode into the SDK config (e.g. an array or primitive). + if json.Valid([]byte(req.ConfigValue.ValueString())) { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid model_config", + "model_config must be a JSON object compatible with Coder's chat model config schema.", + ) + } + return + } + if canonical == "{}" { + resp.Diagnostics.AddAttributeError( + req.Path, + "Empty model_config", + "model_config has no settings, so Coder would discard it and leave Terraform's state inconsistent. Omit the attribute entirely to use Coder's defaults.", + ) + } +} diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go new file mode 100644 index 0000000..8c2dbf8 --- /dev/null +++ b/internal/provider/agents_model_resource.go @@ -0,0 +1,417 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = &AgentsModelResource{} + _ resource.ResourceWithConfigure = &AgentsModelResource{} + _ resource.ResourceWithImportState = &AgentsModelResource{} + _ resource.ResourceWithModifyPlan = &AgentsModelResource{} +) + +func NewAgentsModelResource() resource.Resource { + return &AgentsModelResource{} +} + +type AgentsModelResource struct { + data *CoderdProviderData +} + +func (r *AgentsModelResource) experimentalClient() *codersdk.ExperimentalClient { + return codersdk.NewExperimentalClient(r.data.Client) +} + +type AgentsModelResourceModel struct { + ID UUID `tfsdk:"id"` + AIProviderID UUID `tfsdk:"ai_provider_id"` + ProviderType types.String `tfsdk:"provider_type"` + Model types.String `tfsdk:"model"` + DisplayName types.String `tfsdk:"display_name"` + Enabled types.Bool `tfsdk:"enabled"` + ContextLimit types.Int64 `tfsdk:"context_limit"` + CompressionThreshold types.Int64 `tfsdk:"compression_threshold"` + ModelConfig agentsModelConfigValue `tfsdk:"model_config"` + CreatedAt types.Int64 `tfsdk:"created_at"` + UpdatedAt types.Int64 `tfsdk:"updated_at"` +} + +func (r *AgentsModelResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_agents_model" +} + +func (r *AgentsModelResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.AddWarning( + "Experimental Resource", + "coderd_agents_model is experimental. Changes are expected, and it is not recommended for production use.", + ) +} + +func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "~> This resource is experimental. Changes are to be expected, and we recommend using it with caution in production environments.\n\n" + + "Configures an admin-managed chat model for Coder Agents, binding a model identifier to a configured AI provider (see `coderd_ai_provider`) along with context, compression, and optional JSON tuning settings.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Agents model configuration ID.", + CustomType: UUIDType, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "ai_provider_id": schema.StringAttribute{ + MarkdownDescription: "AI provider ID that backs this model. Usually this is `coderd_ai_provider..id`. Updating it re-derives the read-only `provider_type` from the referenced provider.", + CustomType: UUIDType, + Required: true, + }, + "provider_type": schema.StringAttribute{ + MarkdownDescription: "Provider type derived by Coder from `ai_provider_id`, for example `openai`, `anthropic`, or `bedrock`.", + Computed: true, + PlanModifiers: []planmodifier.String{ + useStateForUnknownUnlessChanged("ai_provider_id"), + }, + }, + "model": schema.StringAttribute{ + MarkdownDescription: "Model identifier to use with the referenced provider.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "Display name shown in Coder.", + Optional: true, + Computed: true, + // Reject "" since Coder ignores a blank update and keeps the prior value, causing drift. + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + codersdkvalidator.DisplayName(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Whether this model configuration is enabled. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "context_limit": schema.Int64Attribute{ + MarkdownDescription: "Maximum context window for this model. Must be greater than zero.", + Required: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + }, + "compression_threshold": schema.Int64Attribute{ + MarkdownDescription: "Percentage of the context window at which Coder should compact chat context. Defaults to 70 and must be between 0 and 100.", + Optional: true, + Computed: true, + Default: int64default.StaticInt64(70), + Validators: []validator.Int64{ + int64validator.Between(0, 100), + }, + }, + // JSON, not typed attributes: ChatModelCallConfig is large, evolving, + // and its provider_options is a tagged union Terraform can't express. + "model_config": schema.StringAttribute{ + MarkdownDescription: "Optional JSON blob of per-call tuning for the model, such as `max_output_tokens`, `temperature`, `top_p`, `cost`, and `provider_options`. See the field reference (including per-provider `provider_options`) at https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", + CustomType: agentsModelConfigType{}, + Optional: true, + Validators: []validator.String{ + agentsModelConfigNotEmptyValidator{}, + }, + PlanModifiers: []planmodifier.String{ + agentsModelConfigUseStateIfSemanticallyEqual{}, + }, + }, + "created_at": schema.Int64Attribute{ + MarkdownDescription: "Creation timestamp as Unix seconds.", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "updated_at": schema.Int64Attribute{ + MarkdownDescription: "Last update timestamp as Unix seconds.", + Computed: true, + // Deliberately NO UseStateForUnknown: unlike created_at, updated_at is + // mutable. The server sets it to NOW() on every update, so pinning the + // prior value makes a real update fail with "inconsistent result after + // apply" whenever the update crosses a one-second boundary (the planned + // timestamp != the server's fresh timestamp). The cosmetic post-refresh + // "known after apply" on imported state is the correct tradeoff. + }, + }, + } +} + +func (r *AgentsModelResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + data, ok := req.ProviderData.(*CoderdProviderData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.data = data +} + +func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan AgentsModelResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + createReq := plan.createRequest(&resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "creating Agents model") + modelConfig, err := r.createChatModelConfigWithRetry(ctx, createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create Agents model, got error: %s", err)) + return + } + + state := stateFromModelConfig(modelConfig, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// createChatModelConfigWithRetry retries CreateChatModelConfig on the 409 +// default-election race (the only unique constraint is the single-default one, +// so a 409 can only be that race). Required until Linear CODAGT-736 is fixed +// server-side; remove this and call CreateChatModelConfig directly once it is. +func (r *AgentsModelResource) createChatModelConfigWithRetry(ctx context.Context, req codersdk.CreateChatModelConfigRequest) (codersdk.ChatModelConfig, error) { + const maxAttempts = 10 + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + config, err := r.experimentalClient().CreateChatModelConfig(ctx, req) + if err == nil { + return config, nil + } + var sdkErr *codersdk.Error + if !errors.As(err, &sdkErr) || sdkErr.StatusCode() != http.StatusConflict { + return codersdk.ChatModelConfig{}, err + } + lastErr = err + select { + case <-ctx.Done(): + return codersdk.ChatModelConfig{}, ctx.Err() + case <-time.After(time.Duration(attempt+1) * 100 * time.Millisecond): + } + } + return codersdk.ChatModelConfig{}, lastErr +} + +func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state AgentsModelResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + modelConfigID := state.ID.ValueUUID() + configs, err := r.experimentalClient().ListChatModelConfigs(ctx) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read Agents model, got error: %s", err)) + return + } + + for _, config := range configs { + if config.ID == modelConfigID { + refreshed := stateFromModelConfig(config, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &refreshed)...) + return + } + } + + resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Agents model with ID %s not found. Marking as deleted.", modelConfigID.String())) + resp.State.RemoveResource(ctx) +} + +func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state AgentsModelResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + updateReq := plan.updateRequest(state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "updating Agents model", map[string]any{"id": state.ID.ValueString()}) + modelConfig, err := r.experimentalClient().UpdateChatModelConfig(ctx, state.ID.ValueUUID(), updateReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update Agents model, got error: %s", err)) + return + } + + updated := stateFromModelConfig(modelConfig, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &updated)...) +} + +func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state AgentsModelResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "deleting Agents model", map[string]any{"id": state.ID.ValueString()}) + if err := r.experimentalClient().DeleteChatModelConfig(ctx, state.ID.ValueUUID()); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete Agents model, got error: %s", err)) + return + } +} + +func (r *AgentsModelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (m AgentsModelResourceModel) createRequest(diags *diag.Diagnostics) codersdk.CreateChatModelConfigRequest { + aiProviderID := m.AIProviderID.ValueUUID() + req := codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProviderID, + Model: m.Model.ValueString(), + DisplayName: m.DisplayName.ValueString(), + Enabled: ptr.Ref(m.Enabled.ValueBool()), + ContextLimit: ptr.Ref(m.ContextLimit.ValueInt64()), + CompressionThreshold: ptr.Ref(int32(m.CompressionThreshold.ValueInt64())), + ModelConfig: agentsModelDecodeConfig(m.ModelConfig, diags), + } + return req +} + +func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, diags *diag.Diagnostics) codersdk.UpdateChatModelConfigRequest { + var req codersdk.UpdateChatModelConfigRequest + if !m.AIProviderID.Equal(state.AIProviderID) { + aiProviderID := m.AIProviderID.ValueUUID() + req.AIProviderID = &aiProviderID + } + if !m.Model.Equal(state.Model) { + req.Model = m.Model.ValueString() + } + if !m.DisplayName.Equal(state.DisplayName) { + req.DisplayName = m.DisplayName.ValueString() + } + if !m.Enabled.Equal(state.Enabled) { + req.Enabled = ptr.Ref(m.Enabled.ValueBool()) + } + if !m.ContextLimit.Equal(state.ContextLimit) { + req.ContextLimit = ptr.Ref(m.ContextLimit.ValueInt64()) + } + if !m.CompressionThreshold.Equal(state.CompressionThreshold) { + req.CompressionThreshold = ptr.Ref(int32(m.CompressionThreshold.ValueInt64())) + } + if !m.ModelConfig.Equal(state.ModelConfig) { + if m.ModelConfig.IsNull() { + // Send an empty object so Coder clears the stored tuning config. + req.ModelConfig = &codersdk.ChatModelCallConfig{} + } else { + req.ModelConfig = agentsModelDecodeConfig(m.ModelConfig, diags) + } + } + return req +} + +func stateFromModelConfig(config codersdk.ChatModelConfig, diags *diag.Diagnostics) AgentsModelResourceModel { + out := AgentsModelResourceModel{ + ID: UUIDValue(config.ID), + ProviderType: types.StringValue(config.Provider), + Model: types.StringValue(config.Model), + DisplayName: types.StringValue(config.DisplayName), + Enabled: types.BoolValue(config.Enabled), + ContextLimit: types.Int64Value(config.ContextLimit), + CompressionThreshold: types.Int64Value(int64(config.CompressionThreshold)), + ModelConfig: agentsModelConfigToState(config.ModelConfig, diags), + CreatedAt: types.Int64Value(config.CreatedAt.Unix()), + UpdatedAt: types.Int64Value(config.UpdatedAt.Unix()), + } + if config.AIProviderID != nil { + out.AIProviderID = UUIDValue(*config.AIProviderID) + } else { + out.AIProviderID = NewUUIDNull() + } + return out +} + +// agentsModelDecodeConfig decodes the model_config JSON string into the SDK +// type. Null or unknown values become nil so the field is omitted from the +// request and Coder keeps its existing value. +func agentsModelDecodeConfig(v agentsModelConfigValue, diags *diag.Diagnostics) *codersdk.ChatModelCallConfig { + if v.IsNull() || v.IsUnknown() { + return nil + } + var config codersdk.ChatModelCallConfig + if err := json.Unmarshal([]byte(v.ValueString()), &config); err != nil { + diags.AddAttributeError(path.Root("model_config"), "Invalid Model Config", fmt.Sprintf("Unable to decode `model_config`: %s", err)) + return nil + } + return &config +} + +// agentsModelConfigToState serializes the model_config returned by Coder back +// into a normalized JSON string. Coder returns null when no tuning config is +// set, which maps to a null attribute. +func agentsModelConfigToState(remote *codersdk.ChatModelCallConfig, diags *diag.Diagnostics) agentsModelConfigValue { + if remote == nil { + return newAgentsModelConfigNull() + } + encoded, err := json.Marshal(remote) + if err != nil { + diags.AddError("Model Config Error", fmt.Sprintf("Unable to encode returned model_config: %s", err)) + return newAgentsModelConfigNull() + } + // Sort keys alphabetically so the stored value matches the user's jsonencode + // config byte-for-byte; otherwise every post-import plan spuriously marks + // updated_at unknown (see agentsModelConfigSortedJSON). + sorted, err := agentsModelConfigSortedJSON(encoded) + if err != nil { + diags.AddError("Model Config Error", fmt.Sprintf("Unable to normalize returned model_config: %s", err)) + return newAgentsModelConfigNull() + } + return newAgentsModelConfigValue(sorted) +} diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go new file mode 100644 index 0000000..f35aa93 --- /dev/null +++ b/internal/provider/agents_model_resource_test.go @@ -0,0 +1,878 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "testing" + "text/template" + "time" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/stretchr/testify/require" +) + +func TestAgentsModelCreateRequest(t *testing.T) { + t.Parallel() + + aiProviderID := uuid.New() + plan := AgentsModelResourceModel{ + AIProviderID: UUIDValue(aiProviderID), + Model: types.StringValue("claude-3-5-sonnet-20241022"), + DisplayName: types.StringValue("Claude 3.5 Sonnet"), + Enabled: types.BoolValue(true), + ContextLimit: types.Int64Value(200000), + CompressionThreshold: types.Int64Value(70), + ModelConfig: newAgentsModelConfigValue(`{"max_output_tokens":8192,"temperature":0.7,"cost":{"input_price_per_million_tokens":"3"}}`), + } + + var diags diag.Diagnostics + req := plan.createRequest(&diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Empty(t, req.Provider, "provider is derived server-side") + require.NotNil(t, req.AIProviderID) + require.Equal(t, aiProviderID, *req.AIProviderID) + require.Equal(t, "claude-3-5-sonnet-20241022", req.Model) + require.Equal(t, "Claude 3.5 Sonnet", req.DisplayName) + require.NotNil(t, req.Enabled) + require.True(t, *req.Enabled) + require.NotNil(t, req.ContextLimit) + require.EqualValues(t, 200000, *req.ContextLimit) + require.NotNil(t, req.CompressionThreshold) + require.EqualValues(t, 70, *req.CompressionThreshold) + require.NotNil(t, req.ModelConfig) + require.NotNil(t, req.ModelConfig.MaxOutputTokens) + require.EqualValues(t, 8192, *req.ModelConfig.MaxOutputTokens) + require.NotNil(t, req.ModelConfig.Cost) + require.NotNil(t, req.ModelConfig.Cost.InputPricePerMillionTokens) + require.Equal(t, "3", req.ModelConfig.Cost.InputPricePerMillionTokens.String()) +} + +func TestAgentsModelUpdateRequestClearsModelConfig(t *testing.T) { + t.Parallel() + + state := AgentsModelResourceModel{ + AIProviderID: UUIDValue(uuid.New()), + Model: types.StringValue("claude-3-5-sonnet-20241022"), + DisplayName: types.StringValue("Claude 3.5 Sonnet"), + Enabled: types.BoolValue(true), + ContextLimit: types.Int64Value(200000), + CompressionThreshold: types.Int64Value(70), + ModelConfig: newAgentsModelConfigValue(`{"max_output_tokens":8192}`), + } + plan := state + plan.ModelConfig = newAgentsModelConfigNull() + + var diags diag.Diagnostics + patch := plan.updateRequest(state, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Equal(t, &codersdk.ChatModelCallConfig{}, patch.ModelConfig, "clearing sends an empty object") + // Unchanged fields are omitted from the patch. + require.Nil(t, patch.AIProviderID) + require.Empty(t, patch.Model) + require.Empty(t, patch.DisplayName) + require.Nil(t, patch.Enabled) + require.Nil(t, patch.ContextLimit) + require.Nil(t, patch.CompressionThreshold) +} + +// A changed field appears in the update patch. ModelConfig is covered above; +// this locks the Enabled transition. +func TestAgentsModelUpdateRequestChangedFields(t *testing.T) { + t.Parallel() + + state := AgentsModelResourceModel{ + AIProviderID: UUIDValue(uuid.New()), + Model: types.StringValue("claude-3-5-sonnet-20241022"), + DisplayName: types.StringValue("Claude 3.5 Sonnet"), + Enabled: types.BoolValue(true), + ContextLimit: types.Int64Value(200000), + CompressionThreshold: types.Int64Value(70), + ModelConfig: newAgentsModelConfigNull(), + } + plan := state + plan.Enabled = types.BoolValue(false) + + var diags diag.Diagnostics + patch := plan.updateRequest(state, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, patch.Enabled) + require.False(t, *patch.Enabled, "a changed Enabled is sent") +} + +func TestAgentsModelStateFromModelConfig(t *testing.T) { + t.Parallel() + + modelConfigID := uuid.New() + aiProviderID := uuid.New() + createdAt := time.Unix(1700000000, 0) + updatedAt := time.Unix(1700000600, 0) + // max_output_tokens is MaxInt64 on purpose: it is not exactly representable + // as a float64, so if the key-sorting step ever decoded numbers into float64 + // (instead of json.Number) it would corrupt this to ...808 and fail the + // exact-bytes assertion below. This is the numeric-no-regression guard. + remote := decodeAgentsModelConfigForTest(t, `{"max_output_tokens":9223372036854775807,"cost":{"input_price_per_million_tokens":"3"}}`) + + var diags diag.Diagnostics + state := stateFromModelConfig(codersdk.ChatModelConfig{ + ID: modelConfigID, + Provider: "anthropic", + AIProviderID: &aiProviderID, + Model: "claude-3-5-sonnet-20241022", + DisplayName: "Claude 3.5 Sonnet", + Enabled: true, + ContextLimit: 200000, + CompressionThreshold: 70, + ModelConfig: remote, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.Equal(t, modelConfigID, state.ID.ValueUUID()) + require.Equal(t, aiProviderID, state.AIProviderID.ValueUUID()) + require.Equal(t, "anthropic", state.ProviderType.ValueString()) + require.Equal(t, "claude-3-5-sonnet-20241022", state.Model.ValueString()) + require.Equal(t, "Claude 3.5 Sonnet", state.DisplayName.ValueString()) + require.True(t, state.Enabled.ValueBool()) + require.EqualValues(t, 200000, state.ContextLimit.ValueInt64()) + require.EqualValues(t, 70, state.CompressionThreshold.ValueInt64()) + require.Equal(t, createdAt.Unix(), state.CreatedAt.ValueInt64()) + require.Equal(t, updatedAt.Unix(), state.UpdatedAt.ValueInt64()) + + // State must store model_config with alphabetically-sorted keys (cost before + // max_output_tokens, recursively) so it byte-matches the user's jsonencode + // config; the SDK struct order would emit max_output_tokens first. Without + // the byte match, every post-import plan spuriously flips updated_at to + // "known after apply". + require.Equal(t, + `{"cost":{"input_price_per_million_tokens":"3"},"max_output_tokens":9223372036854775807}`, + state.ModelConfig.ValueString(), + "state stores model_config with sorted keys and exact number tokens to match jsonencode byte-for-byte") +} + +func TestAgentsModelConfigSemanticEquals(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + prior string + next string + equal bool + }{ + { + name: "decimal trailing zeros", + prior: `{"cost":{"input_price_per_million_tokens":"3"}}`, + next: `{"cost":{"input_price_per_million_tokens":"3.00"}}`, + equal: true, + }, + { + name: "whitespace and key order", + prior: `{"max_output_tokens":8192,"temperature":0.7}`, + next: "{\n \"temperature\": 0.7,\n \"max_output_tokens\": 8192\n}", + equal: true, + }, + { + name: "legacy top-level pricing keys fold into cost", + prior: `{"cost":{"input_price_per_million_tokens":"3"}}`, + next: `{"input_price_per_million_tokens":"3"}`, + equal: true, + }, + { + name: "different values are not equal", + prior: `{"max_output_tokens":8192}`, + next: `{"max_output_tokens":4096}`, + equal: false, + }, + { + name: "empty objects are equal", + prior: `{}`, + next: "{\n}", + equal: true, + }, + { + name: "empty object is not equal to a populated config", + prior: `{}`, + next: `{"max_output_tokens":8192}`, + equal: false, + }, + { + // Non-object JSON cannot canonicalize through the SDK struct, so the + // comparison falls back to jsontypes' JSON-level semantic equality. + name: "non-object json falls back to json equality", + prior: `[1, 2]`, + next: `[1,2]`, + equal: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + next := newAgentsModelConfigValue(tc.next) + equal, diags := next.StringSemanticEquals(t.Context(), newAgentsModelConfigValue(tc.prior)) + require.False(t, diags.HasError(), diags.Errors()) + require.Equal(t, tc.equal, equal) + }) + } +} + +func TestAgentsModelConfigCanonicalJSON(t *testing.T) { + t.Parallel() + + t.Run("empty object canonicalizes to empty object", func(t *testing.T) { + t.Parallel() + got, err := agentsModelConfigCanonicalJSON(`{}`) + require.NoError(t, err) + require.Equal(t, `{}`, got) + }) + + t.Run("keys are ordered deterministically", func(t *testing.T) { + t.Parallel() + // The equality approach relies on json.Marshal emitting struct fields in a + // stable order, so lock that invariant: regardless of input ordering the + // canonical form follows the ChatModelCallConfig field declaration order. + got, err := agentsModelConfigCanonicalJSON(`{"temperature":0.7,"max_output_tokens":8192}`) + require.NoError(t, err) + require.Equal(t, `{"max_output_tokens":8192,"temperature":0.7}`, got) + }) + + t.Run("invalid json returns an error", func(t *testing.T) { + t.Parallel() + _, err := agentsModelConfigCanonicalJSON(`{`) + require.Error(t, err) + }) + + t.Run("non-object json returns an error", func(t *testing.T) { + t.Parallel() + // A JSON array is valid JSON but cannot decode into the SDK struct; this is + // the case that exercises the StringSemanticEquals fallback path. + _, err := agentsModelConfigCanonicalJSON(`[1, 2]`) + require.Error(t, err) + }) +} + +// TestAgentsModelConfigUseStateIfSemanticallyEqual covers the plan modifier that +// fixes the perpetual import-path diff: the plugin framework never runs +// StringSemanticEquals on the config->plan path, so a key-order-only difference +// between Coder's struct-order state JSON and the alphabetical jsonencode config +// would otherwise re-plan forever. The modifier pins the plan to state only when +// the two canonicalize equal. +func TestAgentsModelConfigUseStateIfSemanticallyEqual(t *testing.T) { + t.Parallel() + + mod := agentsModelConfigUseStateIfSemanticallyEqual{} + planAfter := func(state, config, plan types.String) types.String { + resp := planmodifier.StringResponse{PlanValue: plan} + mod.PlanModifyString(t.Context(), planmodifier.StringRequest{ + StateValue: state, + ConfigValue: config, + PlanValue: plan, + }, &resp) + return resp.PlanValue + } + + t.Run("key-order-only difference pins state", func(t *testing.T) { + t.Parallel() + // state is Coder's struct order; config is what jsonencode emits (sorted). + state := types.StringValue(`{"max_output_tokens":8192,"temperature":0.7}`) + config := types.StringValue(`{"temperature":0.7,"max_output_tokens":8192}`) + require.Equal(t, state, planAfter(state, config, config)) + }) + + t.Run("real value change is left alone", func(t *testing.T) { + t.Parallel() + state := types.StringValue(`{"temperature":0.7}`) + config := types.StringValue(`{"temperature":0.2}`) + require.Equal(t, config, planAfter(state, config, config)) + }) + + t.Run("null state is left alone", func(t *testing.T) { + t.Parallel() + config := types.StringValue(`{"temperature":0.7}`) + require.Equal(t, config, planAfter(types.StringNull(), config, config)) + }) + + t.Run("unknown config is left alone", func(t *testing.T) { + t.Parallel() + state := types.StringValue(`{"temperature":0.7}`) + require.Equal(t, types.StringUnknown(), planAfter(state, types.StringUnknown(), types.StringUnknown())) + }) +} + +func TestAgentsModelDecodeConfig(t *testing.T) { + t.Parallel() + + t.Run("null is omitted", func(t *testing.T) { + t.Parallel() + var diags diag.Diagnostics + require.Nil(t, agentsModelDecodeConfig(newAgentsModelConfigNull(), &diags)) + require.False(t, diags.HasError(), diags.Errors()) + }) + + t.Run("valid json decodes", func(t *testing.T) { + t.Parallel() + var diags diag.Diagnostics + got := agentsModelDecodeConfig(newAgentsModelConfigValue(`{"max_output_tokens":8192}`), &diags) + require.False(t, diags.HasError(), diags.Errors()) + require.NotNil(t, got) + require.NotNil(t, got.MaxOutputTokens) + require.EqualValues(t, 8192, *got.MaxOutputTokens) + }) + + t.Run("invalid json reports a diagnostic", func(t *testing.T) { + t.Parallel() + var diags diag.Diagnostics + require.Nil(t, agentsModelDecodeConfig(newAgentsModelConfigValue(`{`), &diags)) + require.True(t, diags.HasError()) + }) +} + +func TestAgentsModelConfigNotEmptyValidator(t *testing.T) { + t.Parallel() + + v := agentsModelConfigNotEmptyValidator{} + validate := func(t *testing.T, config types.String) diag.Diagnostics { + resp := &validator.StringResponse{} + v.ValidateString(t.Context(), validator.StringRequest{ + Path: path.Root("model_config"), + ConfigValue: config, + }, resp) + return resp.Diagnostics + } + + t.Run("empty object is rejected", func(t *testing.T) { + t.Parallel() + require.True(t, validate(t, types.StringValue(`{}`)).HasError()) + }) + + t.Run("empty object with whitespace is rejected", func(t *testing.T) { + t.Parallel() + require.True(t, validate(t, types.StringValue("{\n \n}")).HasError()) + }) + + t.Run("populated config is allowed", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringValue(`{"max_output_tokens":8192}`)).HasError()) + }) + + t.Run("null is allowed", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringNull()).HasError()) + }) + + t.Run("unknown is allowed", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringUnknown()).HasError()) + }) + + t.Run("invalid json is left for the custom type to report", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringValue(`{`)).HasError()) + }) + + t.Run("non-object json is rejected", func(t *testing.T) { + t.Parallel() + require.True(t, validate(t, types.StringValue(`[1,2]`)).HasError()) + }) +} + +func TestAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { + t.Parallel() + + // PlanOnly reaches provider Configure(), which fetches the current user + // and entitlements, so use a mock server instead of an unreachable URL. + srv := newMockServer(nil) + defer srv.Close() + + cfg := `provider "coderd" { + url = "` + srv.URL + `" + token = "test-token" +} + +variable "ai_provider_id" { + type = string +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = var.ai_provider_id + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 +} +` + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // ai_provider_id is unknown during the validate walk even though + // ConfigVariables supplies a concrete plan value. + Config: cfg, + ConfigVariables: config.Variables{ + "ai_provider_id": config.StringVariable(uuid.NewString()), + }, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAgentsModelResource(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "agents_model_acc", integration.UseLicense) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + + cfg1 := testAccAgentsModelResourceConfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + AIProviderID: aiProvider.ID.String(), + Model: "claude-3-5-sonnet-20241022", + DisplayName: "Claude 3.5 Sonnet", + ContextLimit: 200000, + CompressionThreshold: 70, + MaxOutputTokens: 8192, + Temperature: "0.7", + } + cfg2 := cfg1 + cfg2.DisplayName = "Claude 3.5 Sonnet Updated" + cfg2.ContextLimit = 180000 + cfg2.CompressionThreshold = 60 + cfg2.MaxOutputTokens = 4096 + cfg2.Temperature = "0.2" + + // Captured from the applied state so the import step can compare model_config semantically. + var priorModelConfig string + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_agents_model.sonnet", "id"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "ai_provider_id", aiProvider.ID.String()), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "provider_type", "anthropic"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "model", cfg1.Model), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "display_name", cfg1.DisplayName), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "enabled", "true"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "context_limit", "200000"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "compression_threshold", "70"), + testCheckAgentsModelConfig(8192, 0.7), + resource.TestCheckResourceAttrWith("coderd_agents_model.sonnet", "model_config", func(value string) error { + priorModelConfig = value + return nil + }), + ), + }, + { + ResourceName: "coderd_agents_model.sonnet", + ImportState: true, + ImportStateVerify: true, + // Coder serializes model_config fields in struct order while jsonencode sorts them + // alphabetically, so ImportStateVerify's byte comparison can't match it. Compare it + // semantically via ImportStateCheck instead. + ImportStateVerifyIgnore: []string{"model_config"}, + ImportStateCheck: importStateCheckModelConfigEquivalent(&priorModelConfig), + }, + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "display_name", cfg2.DisplayName), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "context_limit", "180000"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "compression_threshold", "60"), + testCheckAgentsModelConfig(4096, 0.2), + ), + }, + { + Config: cfg2.String(t), + PlanOnly: true, + }, + }, + }) +} + +// TestAccAgentsModelResourceModelConfigNoDrift proves the custom type prevents a +// perpetual diff when Coder re-serializes the value (e.g. "3.00" comes back "3"). +func TestAccAgentsModelResourceModelConfigNoDrift(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "agents_model_drift_acc", integration.UseLicense) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + + cfg := fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = %q + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 + + model_config = jsonencode({ + temperature = 0.70 + cost = { + input_price_per_million_tokens = "3.00" + output_price_per_million_tokens = "15.00" + } + }) +} +`, client.URL.String(), client.SessionToken(), aiProvider.ID.String()) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("coderd_agents_model.sonnet", "id"), + ), + }, + { + // Re-planning the identical config must yield an empty plan: this is + // the live proof that decimal trailing zeros do not cause drift. + Config: cfg, + PlanOnly: true, + }, + }, + }) +} + +// TestAccAgentsModelResourceImportNoDrift proves that importing a model and +// re-planning the identical config is a clean, empty plan. +// +// The model is created out-of-band (so import, not a prior apply, is what seeds +// state) and then imported. Read stores model_config as alphabetically-sorted +// JSON (agentsModelConfigToState), which byte-matches the HCL config's jsonencode +// output. top_p/top_k are chosen because the SDK struct order (top_p, top_k) +// differs from jsonencode's alphabetical order (top_k, top_p): if state were +// stored in struct order, the byte mismatch would trip the framework's raw-byte +// plan guard (PlannedState.Raw.Equal(PriorState.Raw)) and flip the computed +// updated_at to "known after apply" on every plan — a perpetual, non-convergent +// diff. Sorting keys in state removes the mismatch, so the re-plan is empty. +func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "agents_model_import_acc", integration.UseLicense) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + + // Create the model out-of-band so state is first populated by import (Read). + exp := codersdk.NewExperimentalClient(client) + created, err := exp.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "claude-3-5-sonnet-20241022", + ContextLimit: ptr.Ref(int64(200000)), + ModelConfig: &codersdk.ChatModelCallConfig{ + TopP: ptr.Ref(0.9), + TopK: ptr.Ref(int64(40)), + }, + }) + require.NoError(t, err, "create chat model config out-of-band") + // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. + t.Cleanup(func() { _ = exp.DeleteChatModelConfig(context.WithoutCancel(t.Context()), created.ID) }) + + cfg := fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = %q + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 + + model_config = jsonencode({ + top_p = 0.9 + top_k = 40 + }) +} +`, client.URL.String(), client.SessionToken(), aiProvider.ID.String()) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Import seeds state with the sorted model_config JSON. + Config: cfg, + ResourceName: "coderd_agents_model.sonnet", + ImportState: true, + ImportStateId: created.ID.String(), + ImportStatePersist: true, + }, + { + // Re-plan against the same config must be a clean no-op: the sorted + // model_config in state byte-matches the jsonencode config, so it + // does not drift and updated_at is not flipped to "known after + // apply". PlanOnly fails if the plan is non-empty. + Config: cfg, + PlanOnly: true, + }, + }, + }) +} + +// TestAccAgentsModelResourceEmptyModelConfig locks in the empty-config guard: an +// empty "{}" is rejected at plan time rather than tripping a post-apply error. +func TestAccAgentsModelResourceEmptyModelConfig(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "agents_model_empty_acc", integration.UseLicense) + aiProvider := createAccAgentsModelAIProvider(ctx, t, client) + + cfg := fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = %q + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 + + model_config = jsonencode({}) +} +`, client.URL.String(), client.SessionToken(), aiProvider.ID.String()) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + ExpectError: regexp.MustCompile(`Empty model_config`), + }, + }, + }) +} + +type testAccAgentsModelResourceConfig struct { + URL string + Token string + AIProviderID string + Model string + DisplayName string + ContextLimit int + CompressionThreshold int + MaxOutputTokens int + Temperature string +} + +func (c testAccAgentsModelResourceConfig) String(t *testing.T) string { + t.Helper() + const tpl = ` +provider "coderd" { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = "{{.AIProviderID}}" + model = "{{.Model}}" + display_name = "{{.DisplayName}}" + enabled = true + context_limit = {{.ContextLimit}} + compression_threshold = {{.CompressionThreshold}} + + model_config = jsonencode({ + max_output_tokens = {{.MaxOutputTokens}} + temperature = {{.Temperature}} + cost = { + input_price_per_million_tokens = "3" + output_price_per_million_tokens = "15" + } + }) +} +` + var out bytes.Buffer + require.NoError(t, template.Must(template.New("agentsModelResource").Parse(tpl)).Execute(&out, c)) + return out.String() +} + +// TestAccAgentsModelResourceProviderTypeRederive covers the provider_type plan +// modifier (useStateForUnknownUnlessChanged on ai_provider_id): provider_type +// stays known in the plan while ai_provider_id is unchanged, and recomputes when +// ai_provider_id points at a provider of a different type. +func TestAccAgentsModelResourceProviderTypeRederive(t *testing.T) { + t.Parallel() + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + ctx := t.Context() + client := integration.StartCoder(ctx, t, "agents_model_provider_type_acc", integration.UseLicense) + anthropic := createAccAgentsModelAIProvider(ctx, t, client) + openai := createAccAgentsModelAIProviderOfType(ctx, t, client, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "openai-agents-model-acc", + DisplayName: "OpenAI Agents Model Acceptance", + Enabled: true, + BaseURL: "https://api.openai.com", + APIKeys: []string{"sk-test-primary-000000"}, + }) + + cfg := func(providerID string, contextLimit int) string { + return fmt.Sprintf(` +provider "coderd" { + url = %q + token = %q +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = %q + model = "claude-3-5-sonnet-20241022" + context_limit = %d +} +`, client.URL.String(), client.SessionToken(), providerID, contextLimit) + } + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg(anthropic.ID.String(), 200000), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPostRefresh: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + Check: resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "provider_type", "anthropic"), + }, + { + // Changing only context_limit (ai_provider_id unchanged) must keep + // provider_type known in the plan instead of "known after apply". + Config: cfg(anthropic.ID.String(), 180000), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownValue("coderd_agents_model.sonnet", tfjsonpath.New("provider_type"), knownvalue.StringExact("anthropic")), + }, + }, + Check: resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "provider_type", "anthropic"), + }, + { + // Changing ai_provider_id to a provider of a different type must + // re-derive provider_type: unknown in the plan, openai after apply. + Config: cfg(openai.ID.String(), 180000), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("coderd_agents_model.sonnet", tfjsonpath.New("provider_type")), + }, + }, + Check: resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "provider_type", "openai"), + }, + }, + }) +} + +func createAccAgentsModelAIProviderOfType(ctx context.Context, t *testing.T, client *codersdk.Client, req codersdk.CreateAIProviderRequest) codersdk.AIProvider { + t.Helper() + provider, err := client.CreateAIProvider(ctx, req) + require.NoError(t, err, "create AI provider for Agents model acceptance test") + t.Cleanup(func() { + _ = client.DeleteAIProvider(context.Background(), provider.ID.String()) + }) + return provider +} + +func createAccAgentsModelAIProvider(ctx context.Context, t *testing.T, client *codersdk.Client) codersdk.AIProvider { + t.Helper() + return createAccAgentsModelAIProviderOfType(ctx, t, client, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "anthropic-agents-model-acc", + DisplayName: "Anthropic Agents Model Acceptance", + Enabled: true, + BaseURL: "https://api.anthropic.com", + APIKeys: []string{"sk-ant-api03-test-primary"}, + }) +} + +func decodeAgentsModelConfigForTest(t *testing.T, raw string) *codersdk.ChatModelCallConfig { + t.Helper() + var config codersdk.ChatModelCallConfig + require.NoError(t, json.Unmarshal([]byte(raw), &config)) + return &config +} + +// importStateCheckModelConfigEquivalent verifies the imported model_config is +// semantically equal to want by canonicalizing both through the SDK type, since +// ImportStateVerify only compares bytes and Coder's field ordering differs from +// jsonencode's. +func importStateCheckModelConfigEquivalent(want *string) resource.ImportStateCheckFunc { + return func(states []*terraform.InstanceState) error { + wantCanonical, err := agentsModelConfigCanonicalJSON(*want) + if err != nil { + return fmt.Errorf("canonicalize expected model_config: %w", err) + } + for _, s := range states { + got, ok := s.Attributes["model_config"] + if !ok { + continue + } + gotCanonical, err := agentsModelConfigCanonicalJSON(got) + if err != nil { + return fmt.Errorf("canonicalize imported model_config: %w", err) + } + if gotCanonical != wantCanonical { + return fmt.Errorf("imported model_config %q not equivalent to %q", got, *want) + } + return nil + } + return fmt.Errorf("imported state has no resource with model_config") + } +} + +func testCheckAgentsModelConfig(maxOutputTokens int64, temperature float64) resource.TestCheckFunc { + return resource.TestCheckResourceAttrWith("coderd_agents_model.sonnet", "model_config", func(value string) error { + var config codersdk.ChatModelCallConfig + if err := json.Unmarshal([]byte(value), &config); err != nil { + return err + } + if config.MaxOutputTokens == nil || *config.MaxOutputTokens != maxOutputTokens { + return fmt.Errorf("expected max_output_tokens %d, got %v", maxOutputTokens, config.MaxOutputTokens) + } + if config.Temperature == nil || *config.Temperature != temperature { + return fmt.Errorf("expected temperature %f, got %v", temperature, config.Temperature) + } + return nil + }) +} diff --git a/internal/provider/plan_modifiers.go b/internal/provider/plan_modifiers.go new file mode 100644 index 0000000..5f9dd3d --- /dev/null +++ b/internal/provider/plan_modifiers.go @@ -0,0 +1,61 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// useStateForUnknownUnlessChanged copies prior state into an unknown Computed value, +// but only while triggerAttr (a root attribute name) is unchanged; otherwise it leaves +// the value unknown for the server to recompute. +func useStateForUnknownUnlessChanged(triggerAttr string) planmodifier.String { + return useStateForUnknownUnlessChangedModifier{triggerAttr: triggerAttr} +} + +type useStateForUnknownUnlessChangedModifier struct { + triggerAttr string +} + +func (m useStateForUnknownUnlessChangedModifier) Description(_ context.Context) string { + return fmt.Sprintf("Preserves the prior value unless %q changes.", m.triggerAttr) +} + +func (m useStateForUnknownUnlessChangedModifier) MarkdownDescription(ctx context.Context) string { + return m.Description(ctx) +} + +func (m useStateForUnknownUnlessChangedModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Do nothing if there is no state (resource is being created). + if req.State.Raw.IsNull() { + return + } + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + // Do nothing if there is an unknown configuration value, otherwise + // interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + triggerPath := path.Root(m.triggerAttr) + var planTrigger, stateTrigger attr.Value + resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, triggerPath, &planTrigger)...) + resp.Diagnostics.Append(req.State.GetAttribute(ctx, triggerPath, &stateTrigger)...) + if resp.Diagnostics.HasError() { + return + } + + // A changed (or not-yet-known) trigger can change the derived value, so + // leave the planned value unknown for the server to fill in. + if !planTrigger.Equal(stateTrigger) { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 65d0293..3dd30cd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -234,6 +234,7 @@ func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resour NewOrganizationSyncSettingsResource, NewOrganizationGroupSyncResource, NewAIProviderResource, + NewAgentsModelResource, } }