From a0b894ae69a8fd010fe5dfb238de3e580d025d41 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 18 Jun 2026 10:26:50 +0000 Subject: [PATCH 01/10] feat: add coderd_agents_model resource Add a Terraform resource for managing Coder Agents admin-managed chat model configurations. A model config binds a model identifier to a configured AI provider via a required ai_provider_id and carries context/compression settings plus an open-ended per-call tuning blob. The model_config attribute is a custom normalized JSON string type so it is passed through to Coder verbatim and compared with JSON semantic equality, avoiding schema churn as Coder adds tuning fields. An empty config (jsonencode({})) is rejected at plan time because Coder discards it, which would otherwise leave Terraform's state inconsistent. Includes full CRUD, import, examples, generated docs, and focused unit/acceptance tests. --- docs/resources/agents_model.md | 100 +++ .../resources/coderd_agents_model/import.sh | 10 + .../resources/coderd_agents_model/resource.tf | 35 + go.mod | 1 + go.sum | 2 + internal/provider/agents_model_config.go | 186 +++++ internal/provider/agents_model_resource.go | 400 +++++++++++ .../provider/agents_model_resource_test.go | 638 ++++++++++++++++++ internal/provider/plan_modifiers.go | 68 ++ internal/provider/provider.go | 1 + 10 files changed, 1441 insertions(+) create mode 100644 docs/resources/agents_model.md create mode 100644 examples/resources/coderd_agents_model/import.sh create mode 100644 examples/resources/coderd_agents_model/resource.tf create mode 100644 internal/provider/agents_model_config.go create mode 100644 internal/provider/agents_model_resource.go create mode 100644 internal/provider/agents_model_resource_test.go create mode 100644 internal/provider/plan_modifiers.go diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md new file mode 100644 index 0000000..2fefb30 --- /dev/null +++ b/docs/resources/agents_model.md @@ -0,0 +1,100 @@ +--- +# 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, default election, and optional JSON tuning settings. + The server owns default election: set is_default = true on at most one model and omit it on the others rather than forcing it to false. +--- + +# 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, default election, and optional JSON tuning settings. + +The server owns default election: set `is_default = true` on at most one model and omit it on the others rather than forcing it to false. + +## Example Usage + +```terraform +// Provider populated from environment variables. +provider "coderd" {} + +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 + is_default = true + context_limit = 200000 + + # Optional per-call tuning. Omit entirely to use Coder's defaults. + model_config = jsonencode({ + max_output_tokens = 8192 + temperature = 0.7 + cost = { + input_price_per_million_tokens = "3" + output_price_per_million_tokens = "15" + } + }) +} +``` + + +## 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. +- `is_default` (Boolean) Whether this is the default model for new chats. Coder manages the single default server-side, so set `is_default = true` on one model and omit it on others. +- `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`. Omit the attribute entirely to use Coder's defaults. + +### 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 +# Import by the Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet 00000000-0000-0000-0000-000000000000 +``` +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 = "00000000-0000-0000-0000-000000000000" +} +``` diff --git a/examples/resources/coderd_agents_model/import.sh b/examples/resources/coderd_agents_model/import.sh new file mode 100644 index 0000000..7ff0844 --- /dev/null +++ b/examples/resources/coderd_agents_model/import.sh @@ -0,0 +1,10 @@ +# Import by the Agents model configuration UUID returned by Coder. +$ terraform import coderd_agents_model.sonnet 00000000-0000-0000-0000-000000000000 +``` +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 = "00000000-0000-0000-0000-000000000000" +} diff --git a/examples/resources/coderd_agents_model/resource.tf b/examples/resources/coderd_agents_model/resource.tf new file mode 100644 index 0000000..6e9db80 --- /dev/null +++ b/examples/resources/coderd_agents_model/resource.tf @@ -0,0 +1,35 @@ +// Provider populated from environment variables. +provider "coderd" {} + +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 + is_default = true + context_limit = 200000 + + # Optional per-call tuning. Omit entirely to use Coder's defaults. + model_config = jsonencode({ + max_output_tokens = 8192 + temperature = 0.7 + cost = { + input_price_per_million_tokens = "3" + output_price_per_million_tokens = "15" + } + }) +} diff --git a/go.mod b/go.mod index 0c882cf..4dd0a05 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( 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 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/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go new file mode 100644 index 0000000..7575a53 --- /dev/null +++ b/internal/provider/agents_model_config.go @@ -0,0 +1,186 @@ +package provider + +import ( + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// agentsModelConfigType is a jsontypes.Normalized whose semantic equality also +// canonicalizes the document through codersdk.ChatModelCallConfig. Coder rewrites +// model_config on its side when it stores and returns it: decimal costs such as +// "3.00" come back as "3", and the legacy top-level pricing keys are folded into +// the nested "cost" object. A plain JSON string would therefore show a perpetual +// diff. Comparing the decoded structs instead lets Terraform treat the value the +// user wrote and the value Coder stores as equal, without the schema having to +// enumerate any fields. +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 documents as equal when they +// decode to the same codersdk.ChatModelCallConfig. The framework only invokes +// this when both values are known and non-null, so the canonical encodings can be +// compared directly. If either document fails to decode we fall back to +// jsontypes' JSON-level comparison; ValidateAttribute surfaces invalid JSON. +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 +} + +// agentsModelConfigNotEmptyValidator rejects a model_config that carries no +// settings, for example jsonencode({}). Coder collapses an all-zero +// ChatModelCallConfig (including an explicit "{}") to null when it stores and +// returns the value: see isZeroChatModelCallConfig in coderd/exp_chats.go. +// Because model_config is Optional (not Computed) and the framework skips +// semantic equality when either side is null, a configured "{}" would disagree +// with the null Coder returns and trip Terraform's "Provider produced +// inconsistent result after apply" check at apply time. An empty config is also +// meaningless: it is identical to omitting the attribute. Rejecting it at plan +// time turns that confusing core error into an actionable message that tells the +// user to omit the attribute instead. +// +// The check round-trips the value through the SDK struct rather than enumerating +// fields, so it stays correct as Coder adds tuning fields: a config that carries +// any set field canonicalizes to something other than "{}" and passes, matching +// Coder, which only collapses configs whose fields are all unset. +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 { + 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..5ceef3f --- /dev/null +++ b/internal/provider/agents_model_resource.go @@ -0,0 +1,400 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + + "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/boolplanmodifier" + "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{} + _ resource.ResourceWithValidateConfig = &AgentsModelResource{} +) + +func NewAgentsModelResource() resource.Resource { + return &AgentsModelResource{} +} + +type AgentsModelResource struct { + client *codersdk.ExperimentalClient +} + +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"` + IsDefault types.Bool `tfsdk:"is_default"` + 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) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var data AgentsModelResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + if !data.IsDefault.IsNull() && !data.IsDefault.IsUnknown() && !data.IsDefault.ValueBool() { + resp.Diagnostics.AddAttributeError( + path.Root("is_default"), + "Invalid is_default", + "Coder elects the default model server-side. Set is_default = true on one model and omit it on others.", + ) + } +} + +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, default election, and optional JSON tuning settings.\n\n" + + "The server owns default election: set `is_default = true` on at most one model and omit it on the others rather than forcing it to false.", + 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), + }, + "is_default": schema.BoolAttribute{ + MarkdownDescription: "Whether this is the default model for new chats. Coder manages the single default server-side, so set `is_default = true` on one model and omit it on others.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "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), + }, + }, + "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`. Omit the attribute entirely to use Coder's defaults.", + CustomType: agentsModelConfigType{}, + Optional: true, + Validators: []validator.String{ + agentsModelConfigNotEmptyValidator{}, + }, + }, + "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, + }, + }, + } +} + +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.client = codersdk.NewExperimentalClient(data.Client) +} + +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.client.CreateChatModelConfig(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)...) +} + +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.client.ListChatModelConfigs(ctx) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to list Agents models, 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.client.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.client.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), + } + // Omitted is_default is unknown (no static default); leave it nil so Coder elects the default. + if !m.IsDefault.IsUnknown() { + req.IsDefault = m.IsDefault.ValueBoolPointer() + } + 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.IsDefault.Equal(state.IsDefault) { + req.IsDefault = ptr.Ref(m.IsDefault.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), + IsDefault: types.BoolValue(config.IsDefault), + 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() + } + return newAgentsModelConfigValue(string(encoded)) +} diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go new file mode 100644 index 0000000..3287408 --- /dev/null +++ b/internal/provider/agents_model_resource_test.go @@ -0,0 +1,638 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "testing" + "text/template" + "time" + + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "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/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), + IsDefault: 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.IsDefault) + require.True(t, *req.IsDefault) + 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), + IsDefault: 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") +} + +func TestAgentsModelStateFromModelConfig(t *testing.T) { + t.Parallel() + + modelConfigID := uuid.New() + aiProviderID := uuid.New() + createdAt := time.Unix(1700000000, 0) + updatedAt := time.Unix(1700000600, 0) + remote := decodeAgentsModelConfigForTest(t, `{"max_output_tokens":8192,"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, + IsDefault: 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.True(t, state.IsDefault.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()) + + expected, err := json.Marshal(remote) + require.NoError(t, err) + require.JSONEq(t, string(expected), state.ModelConfig.ValueString(), "state mirrors the config Coder returns") +} + +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) + }) +} + +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()) + }) +} + +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" + + 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", "is_default", "true"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "context_limit", "200000"), + resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "compression_threshold", "70"), + testCheckAgentsModelConfig(8192, 0.7), + ), + }, + { + ResourceName: "coderd_agents_model.sonnet", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"model_config"}, + }, + { + 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 end-to-end that the custom +// model_config type prevents a perpetual diff when Coder re-serializes the value. +// Cost values are written with trailing zeros ("3.00"); Coder stores them via +// shopspring/decimal and returns them as "3". A plain string (or jsontypes.Normalized) +// attribute would show a diff on every plan; the custom type must not. +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, + }, + }, + }) +} + +// TestAccAgentsModelResourceEmptyModelConfig locks in the empty-config guard. +// Coder collapses an all-zero model_config (including an explicit "{}") to null +// on read, so a configured "{}" would disagree with the null Coder returns and +// trip Terraform's post-apply consistency check. agentsModelConfigNotEmptyValidator +// rejects it at plan time with an actionable message instead, so the user never +// reaches that confusing core error. Omitting model_config is the supported way +// to fall back to Coder's defaults. +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 + is_default = 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{ + { + // Create bound to the anthropic provider. + Config: cfg(anthropic.ID.String(), 200000), + 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() + provider, err := client.CreateAIProvider(ctx, 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"}, + }) + 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 decodeAgentsModelConfigForTest(t *testing.T, raw string) *codersdk.ChatModelCallConfig { + t.Helper() + var config codersdk.ChatModelCallConfig + require.NoError(t, json.Unmarshal([]byte(raw), &config)) + return &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..76ac1f7 --- /dev/null +++ b/internal/provider/plan_modifiers.go @@ -0,0 +1,68 @@ +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 returns a string plan modifier for a Computed +// value that is derived server-side from another attribute (triggerAttr). Like +// the builtin stringplanmodifier.UseStateForUnknown it copies the prior state +// value into an unknown plan, but only while triggerAttr is unchanged. When +// triggerAttr changes the derived value may change too, so the planned value is +// left unknown for the server to recompute. Plain UseStateForUnknown would pin +// the stale value and cause "Provider produced inconsistent result after apply" +// once the source attribute changes. +// +// triggerAttr is a root-level attribute name (passed to path.Root). +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, } } From 703e002ac23e4de241474d68bda18f0ed59907d2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jun 2026 06:54:13 +0000 Subject: [PATCH 02/10] review --- internal/provider/agents_model_config.go | 8 ++ internal/provider/agents_model_resource.go | 26 ++-- .../provider/agents_model_resource_test.go | 135 ++++++++++++++++-- 3 files changed, 147 insertions(+), 22 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 7575a53..81bf77f 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -174,6 +174,14 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re // 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 == "{}" { diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index 5ceef3f..b17b216 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -15,7 +15,6 @@ import ( "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/boolplanmodifier" "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" @@ -38,7 +37,11 @@ func NewAgentsModelResource() resource.Resource { } type AgentsModelResource struct { - client *codersdk.ExperimentalClient + data *CoderdProviderData +} + +func (r *AgentsModelResource) experimentalClient() *codersdk.ExperimentalClient { + return codersdk.NewExperimentalClient(r.data.Client) } type AgentsModelResourceModel struct { @@ -138,9 +141,6 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq MarkdownDescription: "Whether this is the default model for new chats. Coder manages the single default server-side, so set `is_default = true` on one model and omit it on others.", Optional: true, Computed: true, - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.UseStateForUnknown(), - }, }, "context_limit": schema.Int64Attribute{ MarkdownDescription: "Maximum context window for this model. Must be greater than zero.", @@ -193,7 +193,7 @@ func (r *AgentsModelResource) Configure(ctx context.Context, req resource.Config ) return } - r.client = codersdk.NewExperimentalClient(data.Client) + r.data = data } func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { @@ -208,7 +208,7 @@ func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateReq } tflog.Info(ctx, "creating Agents model") - modelConfig, err := r.client.CreateChatModelConfig(ctx, createReq) + modelConfig, err := r.experimentalClient().CreateChatModelConfig(ctx, createReq) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create Agents model, got error: %s", err)) return @@ -229,9 +229,9 @@ func (r *AgentsModelResource) Read(ctx context.Context, req resource.ReadRequest } modelConfigID := state.ID.ValueUUID() - configs, err := r.client.ListChatModelConfigs(ctx) + configs, err := r.experimentalClient().ListChatModelConfigs(ctx) if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to list Agents models, got error: %s", err)) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read Agents model, got error: %s", err)) return } @@ -263,7 +263,7 @@ func (r *AgentsModelResource) Update(ctx context.Context, req resource.UpdateReq } tflog.Info(ctx, "updating Agents model", map[string]any{"id": state.ID.ValueString()}) - modelConfig, err := r.client.UpdateChatModelConfig(ctx, state.ID.ValueUUID(), updateReq) + 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 @@ -284,7 +284,7 @@ func (r *AgentsModelResource) Delete(ctx context.Context, req resource.DeleteReq } tflog.Info(ctx, "deleting Agents model", map[string]any{"id": state.ID.ValueString()}) - if err := r.client.DeleteChatModelConfig(ctx, state.ID.ValueUUID()); err != nil && !isNotFound(err) { + 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 } @@ -327,8 +327,8 @@ func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, if !m.Enabled.Equal(state.Enabled) { req.Enabled = ptr.Ref(m.Enabled.ValueBool()) } - if !m.IsDefault.Equal(state.IsDefault) { - req.IsDefault = ptr.Ref(m.IsDefault.ValueBool()) + if !m.IsDefault.IsNull() && !m.IsDefault.IsUnknown() && !m.IsDefault.Equal(state.IsDefault) { + req.IsDefault = m.IsDefault.ValueBoolPointer() } if !m.ContextLimit.Equal(state.ContextLimit) { req.ContextLimit = ptr.Ref(m.ContextLimit.ValueInt64()) diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 3287408..48c02e2 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -18,9 +18,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "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" ) @@ -84,6 +86,14 @@ func TestAgentsModelUpdateRequestClearsModelConfig(t *testing.T) { 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.IsDefault) + require.Nil(t, patch.ContextLimit) + require.Nil(t, patch.CompressionThreshold) } func TestAgentsModelStateFromModelConfig(t *testing.T) { @@ -299,6 +309,79 @@ func TestAgentsModelConfigNotEmptyValidator(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 TestAgentsModelResourceValidateConfig(t *testing.T) { + t.Parallel() + + cfg := `provider "coderd" { + url = "http://127.0.0.1" + token = "test-token" +} + +resource "coderd_agents_model" "sonnet" { + ai_provider_id = "` + uuid.NewString() + `" + model = "claude-3-5-sonnet-20241022" + context_limit = 200000 + is_default = false +} +` + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: cfg, + ExpectError: regexp.MustCompile(`Invalid is_default`), + }, + }, + }) +} + +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) { @@ -328,6 +411,9 @@ func TestAccAgentsModelResource(t *testing.T) { 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) }, @@ -346,13 +432,21 @@ func TestAccAgentsModelResource(t *testing.T) { 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, + 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), @@ -599,7 +693,7 @@ func createAccAgentsModelAIProviderOfType(ctx context.Context, t *testing.T, cli func createAccAgentsModelAIProvider(ctx context.Context, t *testing.T, client *codersdk.Client) codersdk.AIProvider { t.Helper() - provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + return createAccAgentsModelAIProviderOfType(ctx, t, client, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeAnthropic, Name: "anthropic-agents-model-acc", DisplayName: "Anthropic Agents Model Acceptance", @@ -607,11 +701,6 @@ func createAccAgentsModelAIProvider(ctx context.Context, t *testing.T, client *c BaseURL: "https://api.anthropic.com", APIKeys: []string{"sk-ant-api03-test-primary"}, }) - 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 decodeAgentsModelConfigForTest(t *testing.T, raw string) *codersdk.ChatModelCallConfig { @@ -621,6 +710,34 @@ func decodeAgentsModelConfigForTest(t *testing.T, raw string) *codersdk.ChatMode 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 From f5261b3a0255913d5fb37e98dcf473140a1bdac6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jun 2026 07:22:35 +0000 Subject: [PATCH 03/10] review --- docs/resources/agents_model.md | 7 ++++++- examples/resources/coderd_agents_model/resource.tf | 7 ++++++- internal/provider/agents_model_resource.go | 5 +++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index 2fefb30..dafbc83 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -44,7 +44,6 @@ resource "coderd_agents_model" "sonnet" { is_default = true context_limit = 200000 - # Optional per-call tuning. Omit entirely to use Coder's defaults. model_config = jsonencode({ max_output_tokens = 8192 temperature = 0.7 @@ -52,6 +51,12 @@ resource "coderd_agents_model" "sonnet" { input_price_per_million_tokens = "3" output_price_per_million_tokens = "15" } + provider_options = { + anthropic = { + effort = "high" + thinking = { budget_tokens = 4096 } + } + } }) } ``` diff --git a/examples/resources/coderd_agents_model/resource.tf b/examples/resources/coderd_agents_model/resource.tf index 6e9db80..54e9522 100644 --- a/examples/resources/coderd_agents_model/resource.tf +++ b/examples/resources/coderd_agents_model/resource.tf @@ -23,7 +23,6 @@ resource "coderd_agents_model" "sonnet" { is_default = true context_limit = 200000 - # Optional per-call tuning. Omit entirely to use Coder's defaults. model_config = jsonencode({ max_output_tokens = 8192 temperature = 0.7 @@ -31,5 +30,11 @@ resource "coderd_agents_model" "sonnet" { input_price_per_million_tokens = "3" output_price_per_million_tokens = "15" } + provider_options = { + anthropic = { + effort = "high" + thinking = { budget_tokens = 4096 } + } + } }) } diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index b17b216..dc49b62 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -158,6 +158,11 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq int64validator.Between(0, 100), }, }, + // A JSON string rather than typed attributes: the underlying + // ChatModelCallConfig is large and still evolving, and its + // provider_options is a tagged union Terraform's type system can't + // express. JSON tracks the experimental API via a dependency bump + // instead of schema churn (cf. AWS Bedrock additional_model_request_fields). "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`. Omit the attribute entirely to use Coder's defaults.", CustomType: agentsModelConfigType{}, From b2e5838261d869a134a14ba8f9ec2c3d2790c210 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jun 2026 08:07:44 +0000 Subject: [PATCH 04/10] review --- integration/agents-model-test/main.tf | 123 ++++++++++++++++++ integration/integration_test.go | 39 ++++++ .../provider/agents_model_resource_test.go | 62 +++++++++ 3 files changed, 224 insertions(+) create mode 100644 integration/agents-model-test/main.tf diff --git a/integration/agents-model-test/main.tf b/integration/agents-model-test/main.tf new file mode 100644 index 0000000..450b808 --- /dev/null +++ b/integration/agents-model-test/main.tf @@ -0,0 +1,123 @@ +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" + is_default = true + 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" { + depends_on = [coderd_agents_model.claude_opus] + 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" { + depends_on = [coderd_agents_model.claude_opus] + 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" { + depends_on = [coderd_agents_model.claude_opus] + 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..c7dc2ec 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" @@ -194,6 +195,44 @@ 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)) + + var defaults []string + for _, m := range configs { + if m.IsDefault { + defaults = append(defaults, m.Model) + } + 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) + // JSONEq compares semantically: Coder canonicalizes decimals and key order. + got, err := json.Marshal(m.ModelConfig) + require.NoError(t, err) + assert.JSONEq(t, w.config, string(got), "model_config for %s", m.Model) + } + assert.Equal(t, []string{"claude-opus-4-8"}, defaults) + }, + }, } { t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 48c02e2..3b43d5c 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -96,6 +96,68 @@ func TestAgentsModelUpdateRequestClearsModelConfig(t *testing.T) { require.Nil(t, patch.CompressionThreshold) } +// An unknown is_default (omitted in config) must never be sent: the server owns +// default election, and types.BoolUnknown().ValueBoolPointer() returns &false, +// which would silently demote the model. +func TestAgentsModelRequestIsDefaultUnknown(t *testing.T) { + t.Parallel() + + createPlan := AgentsModelResourceModel{ + AIProviderID: UUIDValue(uuid.New()), + Model: types.StringValue("claude-3-5-sonnet-20241022"), + DisplayName: types.StringValue("Claude 3.5 Sonnet"), + Enabled: types.BoolValue(true), + IsDefault: types.BoolUnknown(), + ContextLimit: types.Int64Value(200000), + CompressionThreshold: types.Int64Value(70), + ModelConfig: newAgentsModelConfigNull(), + } + var createDiags diag.Diagnostics + createReq := createPlan.createRequest(&createDiags) + require.False(t, createDiags.HasError(), createDiags.Errors()) + require.Nil(t, createReq.IsDefault, "unknown is_default must not be sent on create") + + state := createPlan + state.IsDefault = types.BoolValue(true) + updatePlan := state + updatePlan.IsDefault = types.BoolUnknown() + updatePlan.DisplayName = types.StringValue("Renamed") + + var updateDiags diag.Diagnostics + patch := updatePlan.updateRequest(state, &updateDiags) + require.False(t, updateDiags.HasError(), updateDiags.Errors()) + require.Equal(t, "Renamed", patch.DisplayName, "the changed field is still sent") + require.Nil(t, patch.IsDefault, "unknown is_default must not be sent on update") +} + +// A changed field appears in the update patch. ModelConfig is covered above; +// this locks the Enabled and IsDefault transitions. +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), + IsDefault: types.BoolValue(false), + ContextLimit: types.Int64Value(200000), + CompressionThreshold: types.Int64Value(70), + ModelConfig: newAgentsModelConfigNull(), + } + plan := state + plan.Enabled = types.BoolValue(false) + plan.IsDefault = types.BoolValue(true) + + 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") + require.NotNil(t, patch.IsDefault) + require.True(t, *patch.IsDefault, "a changed IsDefault is sent") +} + func TestAgentsModelStateFromModelConfig(t *testing.T) { t.Parallel() From 0785bc295b20f56f4a9a5cbc35e7adf3335fb819 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jun 2026 09:14:45 +0000 Subject: [PATCH 05/10] review --- docs/resources/agents_model.md | 2 +- internal/provider/agents_model_resource.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index dafbc83..c49007a 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -76,7 +76,7 @@ resource "coderd_agents_model" "sonnet" { - `display_name` (String) Display name shown in Coder. - `enabled` (Boolean) Whether this model configuration is enabled. Defaults to true. - `is_default` (Boolean) Whether this is the default model for new chats. Coder manages the single default server-side, so set `is_default = true` on one model and omit it on others. -- `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`. Omit the attribute entirely to use Coder's defaults. +- `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 diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index dc49b62..be83840 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -164,7 +164,7 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq // express. JSON tracks the experimental API via a dependency bump // instead of schema churn (cf. AWS Bedrock additional_model_request_fields). "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`. Omit the attribute entirely to use Coder's defaults.", + 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{ From 38a9064e8f713ae4b41c95081a58578df62b1b55 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jun 2026 09:25:25 +0000 Subject: [PATCH 06/10] clean --- docs/resources/agents_model.md | 3 -- .../resources/coderd_agents_model/resource.tf | 3 -- internal/provider/agents_model_config.go | 35 ++++--------------- internal/provider/agents_model_resource.go | 7 ++-- .../provider/agents_model_resource_test.go | 16 +++------ internal/provider/plan_modifiers.go | 13 ++----- 6 files changed, 15 insertions(+), 62 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index c49007a..e05a84f 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -19,9 +19,6 @@ The server owns default election: set `is_default = true` on at most one model a ## Example Usage ```terraform -// Provider populated from environment variables. -provider "coderd" {} - variable "anthropic_api_key" { type = string sensitive = true diff --git a/examples/resources/coderd_agents_model/resource.tf b/examples/resources/coderd_agents_model/resource.tf index 54e9522..7af4897 100644 --- a/examples/resources/coderd_agents_model/resource.tf +++ b/examples/resources/coderd_agents_model/resource.tf @@ -1,6 +1,3 @@ -// Provider populated from environment variables. -provider "coderd" {} - variable "anthropic_api_key" { type = string sensitive = true diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 81bf77f..0ffec72 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -15,14 +15,8 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) -// agentsModelConfigType is a jsontypes.Normalized whose semantic equality also -// canonicalizes the document through codersdk.ChatModelCallConfig. Coder rewrites -// model_config on its side when it stores and returns it: decimal costs such as -// "3.00" come back as "3", and the legacy top-level pricing keys are folded into -// the nested "cost" object. A plain JSON string would therefore show a perpetual -// diff. Comparing the decoded structs instead lets Terraform treat the value the -// user wrote and the value Coder stores as equal, without the schema having to -// enumerate any fields. +// 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 } @@ -95,11 +89,8 @@ func (v agentsModelConfigValue) Equal(o attr.Value) bool { return false } -// StringSemanticEquals treats two model_config documents as equal when they -// decode to the same codersdk.ChatModelCallConfig. The framework only invokes -// this when both values are known and non-null, so the canonical encodings can be -// compared directly. If either document fails to decode we fall back to -// jsontypes' JSON-level comparison; ValidateAttribute surfaces invalid JSON. +// 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 @@ -139,22 +130,8 @@ func agentsModelConfigCanonicalJSON(raw string) (string, error) { return string(encoded), nil } -// agentsModelConfigNotEmptyValidator rejects a model_config that carries no -// settings, for example jsonencode({}). Coder collapses an all-zero -// ChatModelCallConfig (including an explicit "{}") to null when it stores and -// returns the value: see isZeroChatModelCallConfig in coderd/exp_chats.go. -// Because model_config is Optional (not Computed) and the framework skips -// semantic equality when either side is null, a configured "{}" would disagree -// with the null Coder returns and trip Terraform's "Provider produced -// inconsistent result after apply" check at apply time. An empty config is also -// meaningless: it is identical to omitting the attribute. Rejecting it at plan -// time turns that confusing core error into an actionable message that tells the -// user to omit the attribute instead. -// -// The check round-trips the value through the SDK struct rather than enumerating -// fields, so it stays correct as Coder adds tuning fields: a config that carries -// any set field canonicalizes to something other than "{}" and passes, matching -// Coder, which only collapses configs whose fields are all unset. +// 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{} diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index be83840..3a32d0b 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -158,11 +158,8 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq int64validator.Between(0, 100), }, }, - // A JSON string rather than typed attributes: the underlying - // ChatModelCallConfig is large and still evolving, and its - // provider_options is a tagged union Terraform's type system can't - // express. JSON tracks the experimental API via a dependency bump - // instead of schema churn (cf. AWS Bedrock additional_model_request_fields). + // 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{}, diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 3b43d5c..d2137ca 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -527,11 +527,8 @@ func TestAccAgentsModelResource(t *testing.T) { }) } -// TestAccAgentsModelResourceModelConfigNoDrift proves end-to-end that the custom -// model_config type prevents a perpetual diff when Coder re-serializes the value. -// Cost values are written with trailing zeros ("3.00"); Coder stores them via -// shopspring/decimal and returns them as "3". A plain string (or jsontypes.Normalized) -// attribute would show a diff on every plan; the custom type must not. +// 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") == "" { @@ -583,13 +580,8 @@ resource "coderd_agents_model" "sonnet" { }) } -// TestAccAgentsModelResourceEmptyModelConfig locks in the empty-config guard. -// Coder collapses an all-zero model_config (including an explicit "{}") to null -// on read, so a configured "{}" would disagree with the null Coder returns and -// trip Terraform's post-apply consistency check. agentsModelConfigNotEmptyValidator -// rejects it at plan time with an actionable message instead, so the user never -// reaches that confusing core error. Omitting model_config is the supported way -// to fall back to Coder's defaults. +// 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") == "" { diff --git a/internal/provider/plan_modifiers.go b/internal/provider/plan_modifiers.go index 76ac1f7..5f9dd3d 100644 --- a/internal/provider/plan_modifiers.go +++ b/internal/provider/plan_modifiers.go @@ -9,16 +9,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// useStateForUnknownUnlessChanged returns a string plan modifier for a Computed -// value that is derived server-side from another attribute (triggerAttr). Like -// the builtin stringplanmodifier.UseStateForUnknown it copies the prior state -// value into an unknown plan, but only while triggerAttr is unchanged. When -// triggerAttr changes the derived value may change too, so the planned value is -// left unknown for the server to recompute. Plain UseStateForUnknown would pin -// the stale value and cause "Provider produced inconsistent result after apply" -// once the source attribute changes. -// -// triggerAttr is a root-level attribute name (passed to path.Root). +// 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} } From 383665a3bbd091ad7dea6b6bc50b5387a2865ed4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 25 Jun 2026 09:28:09 +0000 Subject: [PATCH 07/10] clean --- docs/resources/agents_model.md | 6 +++--- examples/resources/coderd_agents_model/import.sh | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index e05a84f..8e79b2e 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -89,14 +89,14 @@ 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 -# Import by the Agents model configuration UUID returned by Coder. -$ terraform import coderd_agents_model.sonnet 00000000-0000-0000-0000-000000000000 +# 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 = "00000000-0000-0000-0000-000000000000" + id = "" } ``` diff --git a/examples/resources/coderd_agents_model/import.sh b/examples/resources/coderd_agents_model/import.sh index 7ff0844..77d8614 100644 --- a/examples/resources/coderd_agents_model/import.sh +++ b/examples/resources/coderd_agents_model/import.sh @@ -1,10 +1,10 @@ -# Import by the Agents model configuration UUID returned by Coder. -$ terraform import coderd_agents_model.sonnet 00000000-0000-0000-0000-000000000000 +# 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 = "00000000-0000-0000-0000-000000000000" + id = "" } From be8cc116f46fa657a893e04aeaca79f747104ed5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 29 Jun 2026 02:33:43 +0000 Subject: [PATCH 08/10] review --- go.mod | 2 +- integration/integration_test.go | 68 +++++++++++++++++-- .../provider/agents_model_resource_test.go | 8 ++- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 4dd0a05..b07f679 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ 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 @@ -86,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/integration/integration_test.go b/integration/integration_test.go index c7dc2ec..642ec81 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -13,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" ) @@ -209,10 +210,61 @@ func TestIntegration(t *testing.T) { // 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"}}}`}, + "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)) @@ -225,10 +277,14 @@ func TestIntegration(t *testing.T) { require.True(t, ok, "unexpected model %s", m.Model) assert.Equal(t, w.provider, m.Provider) require.NotNil(t, m.ModelConfig) - // JSONEq compares semantically: Coder canonicalizes decimals and key order. got, err := json.Marshal(m.ModelConfig) require.NoError(t, err) - assert.JSONEq(t, w.config, string(got), "model_config for %s", m.Model) + 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) + } } assert.Equal(t, []string{"claude-opus-4-8"}, defaults) }, diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index d2137ca..fa5cdd0 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -705,9 +705,13 @@ resource "coderd_agents_model" "sonnet" { ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ { - // Create bound to the anthropic provider. Config: cfg(anthropic.ID.String(), 200000), - Check: resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "provider_type", "anthropic"), + 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 From 3681e2e44f21408d19a3a86e59120ec6eabf3edb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 30 Jun 2026 05:39:06 +0000 Subject: [PATCH 09/10] remove is_default --- docs/resources/agents_model.md | 9 +- .../resources/coderd_agents_model/resource.tf | 1 - integration/agents-model-test/main.tf | 4 - integration/integration_test.go | 5 - internal/provider/agents_model_config.go | 40 +++ internal/provider/agents_model_resource.go | 81 +++--- .../provider/agents_model_resource_test.go | 231 ++++++++++++------ 7 files changed, 242 insertions(+), 129 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index 8e79b2e..602ea06 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -4,17 +4,14 @@ 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, default election, and optional JSON tuning settings. - The server owns default election: set is_default = true on at most one model and omit it on the others rather than forcing it to false. + 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, default election, and optional JSON tuning settings. - -The server owns default election: set `is_default = true` on at most one model and omit it on the others rather than forcing it to false. +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 @@ -38,7 +35,6 @@ resource "coderd_agents_model" "sonnet" { model = "claude-3-5-sonnet-20241022" display_name = "Claude 3.5 Sonnet" enabled = true - is_default = true context_limit = 200000 model_config = jsonencode({ @@ -72,7 +68,6 @@ resource "coderd_agents_model" "sonnet" { - `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. -- `is_default` (Boolean) Whether this is the default model for new chats. Coder manages the single default server-side, so set `is_default = true` on one model and omit it on others. - `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 diff --git a/examples/resources/coderd_agents_model/resource.tf b/examples/resources/coderd_agents_model/resource.tf index 7af4897..62eed4c 100644 --- a/examples/resources/coderd_agents_model/resource.tf +++ b/examples/resources/coderd_agents_model/resource.tf @@ -17,7 +17,6 @@ resource "coderd_agents_model" "sonnet" { model = "claude-3-5-sonnet-20241022" display_name = "Claude 3.5 Sonnet" enabled = true - is_default = true context_limit = 200000 model_config = jsonencode({ diff --git a/integration/agents-model-test/main.tf b/integration/agents-model-test/main.tf index 450b808..5cdee10 100644 --- a/integration/agents-model-test/main.tf +++ b/integration/agents-model-test/main.tf @@ -31,7 +31,6 @@ 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" - is_default = true context_limit = 1000000 compression_threshold = 42 @@ -53,7 +52,6 @@ resource "coderd_agents_model" "claude_opus" { } resource "coderd_agents_model" "claude_sonnet" { - depends_on = [coderd_agents_model.claude_opus] ai_provider_id = coderd_ai_provider.anthropic.id model = "claude-sonnet-4-6" display_name = "Claude Sonnet 4.6" @@ -79,7 +77,6 @@ resource "coderd_agents_model" "claude_sonnet" { } resource "coderd_agents_model" "gpt_xhigh" { - depends_on = [coderd_agents_model.claude_opus] ai_provider_id = coderd_ai_provider.openai.id model = "gpt-5.5" display_name = "GPT-5.5" @@ -106,7 +103,6 @@ resource "coderd_agents_model" "gpt_xhigh" { } resource "coderd_agents_model" "gpt_mini" { - depends_on = [coderd_agents_model.claude_opus] ai_provider_id = coderd_ai_provider.openai.id model = "gpt-5.4-mini" display_name = "GPT-5.4 Mini" diff --git a/integration/integration_test.go b/integration/integration_test.go index 642ec81..a4521e1 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -268,11 +268,7 @@ func TestIntegration(t *testing.T) { } require.Len(t, configs, len(want)) - var defaults []string for _, m := range configs { - if m.IsDefault { - defaults = append(defaults, m.Model) - } w, ok := want[m.Model] require.True(t, ok, "unexpected model %s", m.Model) assert.Equal(t, w.provider, m.Provider) @@ -286,7 +282,6 @@ func TestIntegration(t *testing.T) { t.Errorf("model_config for %s mismatch (-want +got):\n%s", m.Model, diff) } } - assert.Equal(t, []string{"claude-opus-4-8"}, defaults) }, }, } { diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 0ffec72..cdadf1a 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -10,6 +10,7 @@ import ( "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" @@ -130,6 +131,45 @@ func agentsModelConfigCanonicalJSON(raw string) (string, error) { 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{} diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index 3a32d0b..5fb159b 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -3,7 +3,10 @@ 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" @@ -25,11 +28,10 @@ import ( ) var ( - _ resource.Resource = &AgentsModelResource{} - _ resource.ResourceWithConfigure = &AgentsModelResource{} - _ resource.ResourceWithImportState = &AgentsModelResource{} - _ resource.ResourceWithModifyPlan = &AgentsModelResource{} - _ resource.ResourceWithValidateConfig = &AgentsModelResource{} + _ resource.Resource = &AgentsModelResource{} + _ resource.ResourceWithConfigure = &AgentsModelResource{} + _ resource.ResourceWithImportState = &AgentsModelResource{} + _ resource.ResourceWithModifyPlan = &AgentsModelResource{} ) func NewAgentsModelResource() resource.Resource { @@ -51,7 +53,6 @@ type AgentsModelResourceModel struct { Model types.String `tfsdk:"model"` DisplayName types.String `tfsdk:"display_name"` Enabled types.Bool `tfsdk:"enabled"` - IsDefault types.Bool `tfsdk:"is_default"` ContextLimit types.Int64 `tfsdk:"context_limit"` CompressionThreshold types.Int64 `tfsdk:"compression_threshold"` ModelConfig agentsModelConfigValue `tfsdk:"model_config"` @@ -70,26 +71,10 @@ func (r *AgentsModelResource) ModifyPlan(ctx context.Context, req resource.Modif ) } -func (r *AgentsModelResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { - var data AgentsModelResourceModel - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - if !data.IsDefault.IsNull() && !data.IsDefault.IsUnknown() && !data.IsDefault.ValueBool() { - resp.Diagnostics.AddAttributeError( - path.Root("is_default"), - "Invalid is_default", - "Coder elects the default model server-side. Set is_default = true on one model and omit it on others.", - ) - } -} - 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, default election, and optional JSON tuning settings.\n\n" + - "The server owns default election: set `is_default = true` on at most one model and omit it on the others rather than forcing it to false.", + "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.", @@ -137,11 +122,6 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq Computed: true, Default: booldefault.StaticBool(true), }, - "is_default": schema.BoolAttribute{ - MarkdownDescription: "Whether this is the default model for new chats. Coder manages the single default server-side, so set `is_default = true` on one model and omit it on others.", - Optional: true, - Computed: true, - }, "context_limit": schema.Int64Attribute{ MarkdownDescription: "Maximum context window for this model. Must be greater than zero.", Required: true, @@ -167,6 +147,9 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq Validators: []validator.String{ agentsModelConfigNotEmptyValidator{}, }, + PlanModifiers: []planmodifier.String{ + agentsModelConfigUseStateIfSemanticallyEqual{}, + }, }, "created_at": schema.Int64Attribute{ MarkdownDescription: "Creation timestamp as Unix seconds.", @@ -178,6 +161,12 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq "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. }, }, } @@ -210,7 +199,7 @@ func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateReq } tflog.Info(ctx, "creating Agents model") - modelConfig, err := r.experimentalClient().CreateChatModelConfig(ctx, createReq) + 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 @@ -223,6 +212,32 @@ func (r *AgentsModelResource) Create(ctx context.Context, req resource.CreateReq 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)...) @@ -307,10 +322,6 @@ func (m AgentsModelResourceModel) createRequest(diags *diag.Diagnostics) codersd CompressionThreshold: ptr.Ref(int32(m.CompressionThreshold.ValueInt64())), ModelConfig: agentsModelDecodeConfig(m.ModelConfig, diags), } - // Omitted is_default is unknown (no static default); leave it nil so Coder elects the default. - if !m.IsDefault.IsUnknown() { - req.IsDefault = m.IsDefault.ValueBoolPointer() - } return req } @@ -329,9 +340,6 @@ func (m AgentsModelResourceModel) updateRequest(state AgentsModelResourceModel, if !m.Enabled.Equal(state.Enabled) { req.Enabled = ptr.Ref(m.Enabled.ValueBool()) } - if !m.IsDefault.IsNull() && !m.IsDefault.IsUnknown() && !m.IsDefault.Equal(state.IsDefault) { - req.IsDefault = m.IsDefault.ValueBoolPointer() - } if !m.ContextLimit.Equal(state.ContextLimit) { req.ContextLimit = ptr.Ref(m.ContextLimit.ValueInt64()) } @@ -356,7 +364,6 @@ func stateFromModelConfig(config codersdk.ChatModelConfig, diags *diag.Diagnosti Model: types.StringValue(config.Model), DisplayName: types.StringValue(config.DisplayName), Enabled: types.BoolValue(config.Enabled), - IsDefault: types.BoolValue(config.IsDefault), ContextLimit: types.Int64Value(config.ContextLimit), CompressionThreshold: types.Int64Value(int64(config.CompressionThreshold)), ModelConfig: agentsModelConfigToState(config.ModelConfig, diags), diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index fa5cdd0..cdfb4ca 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -11,11 +11,13 @@ import ( "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" @@ -36,7 +38,6 @@ func TestAgentsModelCreateRequest(t *testing.T) { Model: types.StringValue("claude-3-5-sonnet-20241022"), DisplayName: types.StringValue("Claude 3.5 Sonnet"), Enabled: types.BoolValue(true), - IsDefault: 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"}}`), @@ -52,8 +53,6 @@ func TestAgentsModelCreateRequest(t *testing.T) { require.Equal(t, "Claude 3.5 Sonnet", req.DisplayName) require.NotNil(t, req.Enabled) require.True(t, *req.Enabled) - require.NotNil(t, req.IsDefault) - require.True(t, *req.IsDefault) require.NotNil(t, req.ContextLimit) require.EqualValues(t, 200000, *req.ContextLimit) require.NotNil(t, req.CompressionThreshold) @@ -74,7 +73,6 @@ func TestAgentsModelUpdateRequestClearsModelConfig(t *testing.T) { Model: types.StringValue("claude-3-5-sonnet-20241022"), DisplayName: types.StringValue("Claude 3.5 Sonnet"), Enabled: types.BoolValue(true), - IsDefault: types.BoolValue(true), ContextLimit: types.Int64Value(200000), CompressionThreshold: types.Int64Value(70), ModelConfig: newAgentsModelConfigValue(`{"max_output_tokens":8192}`), @@ -91,47 +89,12 @@ func TestAgentsModelUpdateRequestClearsModelConfig(t *testing.T) { require.Empty(t, patch.Model) require.Empty(t, patch.DisplayName) require.Nil(t, patch.Enabled) - require.Nil(t, patch.IsDefault) require.Nil(t, patch.ContextLimit) require.Nil(t, patch.CompressionThreshold) } -// An unknown is_default (omitted in config) must never be sent: the server owns -// default election, and types.BoolUnknown().ValueBoolPointer() returns &false, -// which would silently demote the model. -func TestAgentsModelRequestIsDefaultUnknown(t *testing.T) { - t.Parallel() - - createPlan := AgentsModelResourceModel{ - AIProviderID: UUIDValue(uuid.New()), - Model: types.StringValue("claude-3-5-sonnet-20241022"), - DisplayName: types.StringValue("Claude 3.5 Sonnet"), - Enabled: types.BoolValue(true), - IsDefault: types.BoolUnknown(), - ContextLimit: types.Int64Value(200000), - CompressionThreshold: types.Int64Value(70), - ModelConfig: newAgentsModelConfigNull(), - } - var createDiags diag.Diagnostics - createReq := createPlan.createRequest(&createDiags) - require.False(t, createDiags.HasError(), createDiags.Errors()) - require.Nil(t, createReq.IsDefault, "unknown is_default must not be sent on create") - - state := createPlan - state.IsDefault = types.BoolValue(true) - updatePlan := state - updatePlan.IsDefault = types.BoolUnknown() - updatePlan.DisplayName = types.StringValue("Renamed") - - var updateDiags diag.Diagnostics - patch := updatePlan.updateRequest(state, &updateDiags) - require.False(t, updateDiags.HasError(), updateDiags.Errors()) - require.Equal(t, "Renamed", patch.DisplayName, "the changed field is still sent") - require.Nil(t, patch.IsDefault, "unknown is_default must not be sent on update") -} - // A changed field appears in the update patch. ModelConfig is covered above; -// this locks the Enabled and IsDefault transitions. +// this locks the Enabled transition. func TestAgentsModelUpdateRequestChangedFields(t *testing.T) { t.Parallel() @@ -140,22 +103,18 @@ func TestAgentsModelUpdateRequestChangedFields(t *testing.T) { Model: types.StringValue("claude-3-5-sonnet-20241022"), DisplayName: types.StringValue("Claude 3.5 Sonnet"), Enabled: types.BoolValue(true), - IsDefault: types.BoolValue(false), ContextLimit: types.Int64Value(200000), CompressionThreshold: types.Int64Value(70), ModelConfig: newAgentsModelConfigNull(), } plan := state plan.Enabled = types.BoolValue(false) - plan.IsDefault = types.BoolValue(true) 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") - require.NotNil(t, patch.IsDefault) - require.True(t, *patch.IsDefault, "a changed IsDefault is sent") } func TestAgentsModelStateFromModelConfig(t *testing.T) { @@ -175,7 +134,6 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { Model: "claude-3-5-sonnet-20241022", DisplayName: "Claude 3.5 Sonnet", Enabled: true, - IsDefault: true, ContextLimit: 200000, CompressionThreshold: 70, ModelConfig: remote, @@ -189,7 +147,6 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { 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.True(t, state.IsDefault.ValueBool()) require.EqualValues(t, 200000, state.ContextLimit.ValueInt64()) require.EqualValues(t, 70, state.CompressionThreshold.ValueInt64()) require.Equal(t, createdAt.Unix(), state.CreatedAt.ValueInt64()) @@ -301,6 +258,54 @@ func TestAgentsModelConfigCanonicalJSON(t *testing.T) { }) } +// 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() @@ -378,33 +383,6 @@ func TestAgentsModelConfigNotEmptyValidator(t *testing.T) { }) } -func TestAgentsModelResourceValidateConfig(t *testing.T) { - t.Parallel() - - cfg := `provider "coderd" { - url = "http://127.0.0.1" - token = "test-token" -} - -resource "coderd_agents_model" "sonnet" { - ai_provider_id = "` + uuid.NewString() + `" - model = "claude-3-5-sonnet-20241022" - context_limit = 200000 - is_default = false -} -` - resource.Test(t, resource.TestCase{ - IsUnitTest: true, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: cfg, - ExpectError: regexp.MustCompile(`Invalid is_default`), - }, - }, - }) -} - func TestAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { t.Parallel() @@ -490,7 +468,6 @@ func TestAccAgentsModelResource(t *testing.T) { 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", "is_default", "true"), resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "context_limit", "200000"), resource.TestCheckResourceAttr("coderd_agents_model.sonnet", "compression_threshold", "70"), testCheckAgentsModelConfig(8192, 0.7), @@ -580,6 +557,111 @@ resource "coderd_agents_model" "sonnet" { }) } +// TestAccAgentsModelResourceImportNoDrift reproduces the import-path perpetual +// model_config diff that agentsModelConfigUseStateIfSemanticallyEqual fixes. +// +// The model is created out-of-band (so import, not a prior apply, is what seeds +// state) and then imported. Read stores Coder's struct-order model_config JSON, +// whereas the HCL config's jsonencode emits keys alphabetically. The plugin +// framework only runs StringSemanticEquals against state during refresh/apply, +// never against the config on the plan path, so without the plan modifier the +// struct-order state perpetually diffs against the alphabetical config. top_p/ +// top_k are chosen because their struct order (top_p, top_k) differs from +// jsonencode's alphabetical order (top_k, top_p), so a missing modifier yields a +// real diff rather than a coincidental byte match. +// +// The assertion targets model_config specifically via ExpectKnownValue: with the +// modifier, model_config plans as a known value equal to the imported state JSON +// (no change); without it, model_config would plan as the alphabetical config +// string and the exact-match check fails. This is deliberately scoped to +// model_config rather than ExpectEmptyPlan so it does not depend on updated_at, +// which correctly plans "known after apply" on externally seeded state (pinning +// it would break real updates — see the updated_at schema comment). +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), + // which serializes model_config in Coder's struct field order. + 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) }) + + // The provider stores model_config as Coder's struct-order JSON after import; + // the plan modifier must keep that exact value (not the alphabetical config). + wantModelConfig, err := agentsModelConfigCanonicalJSON(`{"top_p":0.9,"top_k":40}`) + require.NoError(t, err) + + 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 Coder's struct-order model_config JSON. + Config: cfg, + ResourceName: "coderd_agents_model.sonnet", + ImportState: true, + ImportStateId: created.ID.String(), + ImportStatePersist: true, + }, + { + // Re-plan against the same config: model_config must stay a known + // value equal to the imported struct-order JSON. The PreApply check + // runs at plan time and fails if model_config drifts (PlanOnly can't + // be combined with ConfigPlanChecks, so this is a normal apply step; + // updated_at's correct "known after apply" is simply ignored here). + Config: cfg, + // updated_at correctly re-plans as "known after apply" on the next + // refresh, so the post-apply plan is legitimately non-empty. + ExpectNonEmptyPlan: true, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectKnownValue( + "coderd_agents_model.sonnet", + tfjsonpath.New("model_config"), + knownvalue.StringExact(wantModelConfig), + ), + }, + }, + }, + }, + }) +} + // 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) { @@ -644,7 +726,6 @@ resource "coderd_agents_model" "sonnet" { model = "{{.Model}}" display_name = "{{.DisplayName}}" enabled = true - is_default = true context_limit = {{.ContextLimit}} compression_threshold = {{.CompressionThreshold}} From 085ff56f3230c7e52b67928abe0d1b7dae158d92 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 30 Jun 2026 13:02:54 +0000 Subject: [PATCH 10/10] sort model_config --- internal/provider/agents_model_config.go | 23 ++++++ internal/provider/agents_model_resource.go | 10 ++- .../provider/agents_model_resource_test.go | 80 ++++++++----------- 3 files changed, 64 insertions(+), 49 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index cdadf1a..b3f479f 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -1,6 +1,7 @@ package provider import ( + "bytes" "context" "encoding/json" "fmt" @@ -131,6 +132,28 @@ func agentsModelConfigCanonicalJSON(raw string) (string, error) { 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, diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index 5fb159b..8c2dbf8 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -405,5 +405,13 @@ func agentsModelConfigToState(remote *codersdk.ChatModelCallConfig, diags *diag. diags.AddError("Model Config Error", fmt.Sprintf("Unable to encode returned model_config: %s", err)) return newAgentsModelConfigNull() } - return newAgentsModelConfigValue(string(encoded)) + // 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 index cdfb4ca..f35aa93 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -124,7 +124,11 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { aiProviderID := uuid.New() createdAt := time.Unix(1700000000, 0) updatedAt := time.Unix(1700000600, 0) - remote := decodeAgentsModelConfigForTest(t, `{"max_output_tokens":8192,"cost":{"input_price_per_million_tokens":"3"}}`) + // 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{ @@ -152,9 +156,15 @@ func TestAgentsModelStateFromModelConfig(t *testing.T) { require.Equal(t, createdAt.Unix(), state.CreatedAt.ValueInt64()) require.Equal(t, updatedAt.Unix(), state.UpdatedAt.ValueInt64()) - expected, err := json.Marshal(remote) - require.NoError(t, err) - require.JSONEq(t, string(expected), state.ModelConfig.ValueString(), "state mirrors the config Coder returns") + // 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) { @@ -557,26 +567,18 @@ resource "coderd_agents_model" "sonnet" { }) } -// TestAccAgentsModelResourceImportNoDrift reproduces the import-path perpetual -// model_config diff that agentsModelConfigUseStateIfSemanticallyEqual fixes. +// 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 Coder's struct-order model_config JSON, -// whereas the HCL config's jsonencode emits keys alphabetically. The plugin -// framework only runs StringSemanticEquals against state during refresh/apply, -// never against the config on the plan path, so without the plan modifier the -// struct-order state perpetually diffs against the alphabetical config. top_p/ -// top_k are chosen because their struct order (top_p, top_k) differs from -// jsonencode's alphabetical order (top_k, top_p), so a missing modifier yields a -// real diff rather than a coincidental byte match. -// -// The assertion targets model_config specifically via ExpectKnownValue: with the -// modifier, model_config plans as a known value equal to the imported state JSON -// (no change); without it, model_config would plan as the alphabetical config -// string and the exact-match check fails. This is deliberately scoped to -// model_config rather than ExpectEmptyPlan so it does not depend on updated_at, -// which correctly plans "known after apply" on externally seeded state (pinning -// it would break real updates — see the updated_at schema comment). +// 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") == "" { @@ -586,8 +588,7 @@ func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { 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), - // which serializes model_config in Coder's struct field order. + // 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, @@ -602,11 +603,6 @@ func TestAccAgentsModelResourceImportNoDrift(t *testing.T) { // WithoutCancel: t.Context() is already cancelled by the time cleanup runs. t.Cleanup(func() { _ = exp.DeleteChatModelConfig(context.WithoutCancel(t.Context()), created.ID) }) - // The provider stores model_config as Coder's struct-order JSON after import; - // the plan modifier must keep that exact value (not the alphabetical config). - wantModelConfig, err := agentsModelConfigCanonicalJSON(`{"top_p":0.9,"top_k":40}`) - require.NoError(t, err) - cfg := fmt.Sprintf(` provider "coderd" { url = %q @@ -631,7 +627,7 @@ resource "coderd_agents_model" "sonnet" { ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ { - // Import seeds state with Coder's struct-order model_config JSON. + // Import seeds state with the sorted model_config JSON. Config: cfg, ResourceName: "coderd_agents_model.sonnet", ImportState: true, @@ -639,24 +635,12 @@ resource "coderd_agents_model" "sonnet" { ImportStatePersist: true, }, { - // Re-plan against the same config: model_config must stay a known - // value equal to the imported struct-order JSON. The PreApply check - // runs at plan time and fails if model_config drifts (PlanOnly can't - // be combined with ConfigPlanChecks, so this is a normal apply step; - // updated_at's correct "known after apply" is simply ignored here). - Config: cfg, - // updated_at correctly re-plans as "known after apply" on the next - // refresh, so the post-apply plan is legitimately non-empty. - ExpectNonEmptyPlan: true, - ConfigPlanChecks: resource.ConfigPlanChecks{ - PreApply: []plancheck.PlanCheck{ - plancheck.ExpectKnownValue( - "coderd_agents_model.sonnet", - tfjsonpath.New("model_config"), - knownvalue.StringExact(wantModelConfig), - ), - }, - }, + // 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, }, }, })