From c6602821b27d0d1a446eb860d692df29778bfec5 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 04:59:41 +0000 Subject: [PATCH 01/11] feat(internal/provider): reject model_config settings Coder would silently drop Add a plan-time validator for coderd_agents_model.model_config that canonicalizes the user's JSON through codersdk.ChatModelCallConfig and compares object key names before and after. Any key present in the input but gone after the round-trip is a setting Coder does not recognize and would silently discard, so the validator raises a plan-time error naming those keys instead of losing configuration with no diff. json.DisallowUnknownFields cannot be used here because the SDK's custom UnmarshalJSON calls json.Unmarshal internally, which defeats strict decoding for the whole subtree. --- internal/provider/agents_model_config.go | 117 ++++++++++++++++++ internal/provider/agents_model_resource.go | 1 + .../provider/agents_model_resource_test.go | 79 ++++++++++++ 3 files changed, 197 insertions(+) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index b3f479f..4181487 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -5,6 +5,9 @@ import ( "context" "encoding/json" "fmt" + "slices" + "sort" + "strings" "github.com/coder/coder/v2/codersdk" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" @@ -232,3 +235,117 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re ) } } + +// agentsModelConfigDroppedKeys returns the object key names present in raw that +// disappear after canonicalizing through codersdk.ChatModelCallConfig. The SDK +// silently discards keys it does not recognize (its custom UnmarshalJSON calls +// json.Unmarshal internally, which defeats json.DisallowUnknownFields), so a +// removed or misspelled field would otherwise vanish with no plan diff and no +// error. Comparing the set of key names before and after the round-trip surfaces +// those drops. Keys the SDK relocates but keeps (e.g. legacy top-level pricing +// keys migrated under "cost") reappear under the same name and are not reported. +func agentsModelConfigDroppedKeys(raw string) ([]string, error) { + inputKeys, err := agentsModelConfigKeySet(raw) + if err != nil { + return nil, err + } + canonical, err := agentsModelConfigCanonicalJSON(raw) + if err != nil { + return nil, err + } + outputKeys, err := agentsModelConfigKeySet(canonical) + if err != nil { + return nil, err + } + var dropped []string + for k := range inputKeys { + if _, ok := outputKeys[k]; !ok { + dropped = append(dropped, k) + } + } + sort.Strings(dropped) + return dropped, nil +} + +// agentsModelConfigKeySet collects every object key name appearing anywhere in a +// JSON document. +func agentsModelConfigKeySet(raw string) (map[string]struct{}, error) { + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + keys := map[string]struct{}{} + collectJSONKeys(v, keys) + return keys, nil +} + +func collectJSONKeys(v any, keys map[string]struct{}) { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + keys[k] = struct{}{} + collectJSONKeys(val, keys) + } + case []any: + for _, val := range t { + collectJSONKeys(val, keys) + } + } +} + +// agentsModelConfigNoDroppedKeysValidator rejects a model_config whose keys are +// silently discarded when Coder canonicalizes it. Without this a schema change +// in codersdk.ChatModelCallConfig (a removed or renamed field) would drop the +// user's setting with no plan-time error, leaving Terraform to consider the +// resulting state semantically equal. Erroring at plan time forces users to +// migrate to the current schema instead of losing configuration. +type agentsModelConfigNoDroppedKeysValidator struct{} + +var _ validator.String = agentsModelConfigNoDroppedKeysValidator{} + +func (v agentsModelConfigNoDroppedKeysValidator) Description(_ context.Context) string { + return "model_config must not contain settings that Coder does not recognize and would silently discard." +} + +func (v agentsModelConfigNoDroppedKeysValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + // Invalid or non-object JSON is left for the custom type and the not-empty + // validator to report. + dropped, err := agentsModelConfigDroppedKeys(req.ConfigValue.ValueString()) + if err != nil { + return + } + if len(dropped) == 0 { + return + } + detail := fmt.Sprintf( + "These model_config settings are not part of Coder's chat model config schema and would be silently discarded: %s. "+ + "Remove them or update them to the current schema. See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", + strings.Join(dropped, ", "), + ) + if slices.Contains(dropped, "effort") { + detail += " Reasoning effort is now configured with the top-level \"reasoning_effort\" object ({\"default\":..., \"max\":...}) instead of provider_options.*.effort." + } + resp.Diagnostics.AddAttributeError( + req.Path, + "Unrecognized model_config settings", + detail, + ) +} + +func sliceContains(s []string, target string) (int, bool) { + for i, v := range s { + if v == target { + return i, true + } + } + return 0, false +} diff --git a/internal/provider/agents_model_resource.go b/internal/provider/agents_model_resource.go index 30a6259..df626e3 100644 --- a/internal/provider/agents_model_resource.go +++ b/internal/provider/agents_model_resource.go @@ -146,6 +146,7 @@ func (r *AgentsModelResource) Schema(ctx context.Context, req resource.SchemaReq Optional: true, Validators: []validator.String{ agentsModelConfigNotEmptyValidator{}, + agentsModelConfigNoDroppedKeysValidator{}, }, PlanModifiers: []planmodifier.String{ agentsModelConfigUseStateIfSemanticallyEqual{}, diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 21fea26..004b66f 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -391,6 +391,85 @@ func TestAgentsModelConfigNotEmptyValidator(t *testing.T) { }) } +func TestAgentsModelConfigDroppedKeys(t *testing.T) { + t.Parallel() + + t.Run("recognized config drops nothing", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"max_output_tokens":8192,"temperature":0.7,"top_p":0.9}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("unknown top-level key is reported", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"temperature":0.7,"nonsense":true}`) + require.NoError(t, err) + require.Equal(t, []string{"nonsense"}, dropped) + }) + + t.Run("unknown nested key is reported", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"anthropic":{"bogus_setting":"x","thinking":{"budget_tokens":4096}}}}`) + require.NoError(t, err) + require.Equal(t, []string{"bogus_setting"}, dropped) + }) + + t.Run("multiple unknown keys are reported sorted", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"zeta":1,"alpha":2,"temperature":0.7}`) + require.NoError(t, err) + require.Equal(t, []string{"alpha", "zeta"}, dropped) + }) + + t.Run("invalid json returns error", func(t *testing.T) { + t.Parallel() + _, err := agentsModelConfigDroppedKeys(`{`) + require.Error(t, err) + }) +} + +func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { + t.Parallel() + + v := agentsModelConfigNoDroppedKeysValidator{} + 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("recognized config is allowed", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringValue(`{"max_output_tokens":8192}`)).HasError()) + }) + + t.Run("dropped key is rejected and named", func(t *testing.T) { + t.Parallel() + diags := validate(t, types.StringValue(`{"temperature":0.7,"bogus_setting":"x"}`)) + require.True(t, diags.HasError()) + require.Contains(t, diags[0].Detail(), "bogus_setting") + }) + + 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 deferred", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringValue(`{`)).HasError()) + }) +} + func TestAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { t.Parallel() From 7934eebbecdef1438217a792205391ff7fd0536a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 06:22:59 +0000 Subject: [PATCH 02/11] fix(internal/provider): detect dropped model_config keys by path Compare model_config key paths instead of bare names so a valid key can't mask a dropped occurrence of the same name elsewhere, with an exception for the SDK's legacy top-level pricing keys folded under cost. Also drops the unused sliceContains helper that failed lint. --- internal/provider/agents_model_config.go | 105 ++++++++++++------ .../provider/agents_model_resource_test.go | 27 ++++- 2 files changed, 94 insertions(+), 38 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 4181487..20bcf5b 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -236,16 +236,13 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re } } -// agentsModelConfigDroppedKeys returns the object key names present in raw that -// disappear after canonicalizing through codersdk.ChatModelCallConfig. The SDK -// silently discards keys it does not recognize (its custom UnmarshalJSON calls -// json.Unmarshal internally, which defeats json.DisallowUnknownFields), so a -// removed or misspelled field would otherwise vanish with no plan diff and no -// error. Comparing the set of key names before and after the round-trip surfaces -// those drops. Keys the SDK relocates but keeps (e.g. legacy top-level pricing -// keys migrated under "cost") reappear under the same name and are not reported. +// agentsModelConfigDroppedKeys returns the dotted key paths in raw that the SDK +// silently discards when canonicalizing through codersdk.ChatModelCallConfig. +// Comparing paths (not bare names) means a key valid in one place can't mask a +// dropped occurrence of the same name elsewhere. Only the shallowest dropped +// path per subtree is returned. func agentsModelConfigDroppedKeys(raw string) ([]string, error) { - inputKeys, err := agentsModelConfigKeySet(raw) + inputPaths, err := agentsModelConfigKeyPaths(raw) if err != nil { return nil, err } @@ -253,44 +250,87 @@ func agentsModelConfigDroppedKeys(raw string) ([]string, error) { if err != nil { return nil, err } - outputKeys, err := agentsModelConfigKeySet(canonical) + outputPaths, err := agentsModelConfigKeyPaths(canonical) if err != nil { return nil, err } - var dropped []string - for k := range inputKeys { - if _, ok := outputKeys[k]; !ok { - dropped = append(dropped, k) + dropped := map[string]struct{}{} + for p := range inputPaths { + if _, ok := outputPaths[agentsModelConfigCanonicalPath(p)]; !ok { + dropped[p] = struct{}{} } } - sort.Strings(dropped) - return dropped, nil + var out []string + for p := range dropped { + if !agentsModelConfigHasDroppedAncestor(p, dropped) { + out = append(out, p) + } + } + sort.Strings(out) + return out, nil } -// agentsModelConfigKeySet collects every object key name appearing anywhere in a -// JSON document. -func agentsModelConfigKeySet(raw string) (map[string]struct{}, error) { +// agentsModelConfigLegacyPricingKeys are the pre-"cost" top-level pricing keys +// the SDK still accepts and folds under "cost" on read. This relocation is the +// only reason agentsModelConfigCanonicalPath exists. +var agentsModelConfigLegacyPricingKeys = map[string]struct{}{ + "input_price_per_million_tokens": {}, + "output_price_per_million_tokens": {}, + "cache_read_price_per_million_tokens": {}, + "cache_write_price_per_million_tokens": {}, +} + +// agentsModelConfigCanonicalPath maps a legacy top-level pricing key to its +// post-canonicalization path under "cost", leaving every other path unchanged. +func agentsModelConfigCanonicalPath(path string) string { + if _, ok := agentsModelConfigLegacyPricingKeys[path]; ok { + return "cost." + path + } + return path +} + +// agentsModelConfigHasDroppedAncestor reports whether any parent of path is +// itself dropped. +func agentsModelConfigHasDroppedAncestor(path string, dropped map[string]struct{}) bool { + for i := 0; i < len(path); i++ { + if path[i] != '.' { + continue + } + if _, ok := dropped[path[:i]]; ok { + return true + } + } + return false +} + +// agentsModelConfigKeyPaths collects the dotted path of every object key in a +// JSON document. Arrays are traversed without an index segment. +func agentsModelConfigKeyPaths(raw string) (map[string]struct{}, error) { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() var v any if err := dec.Decode(&v); err != nil { return nil, err } - keys := map[string]struct{}{} - collectJSONKeys(v, keys) - return keys, nil + paths := map[string]struct{}{} + collectJSONKeyPaths("", v, paths) + return paths, nil } -func collectJSONKeys(v any, keys map[string]struct{}) { +func collectJSONKeyPaths(prefix string, v any, paths map[string]struct{}) { switch t := v.(type) { case map[string]any: for k, val := range t { - keys[k] = struct{}{} - collectJSONKeys(val, keys) + path := k + if prefix != "" { + path = prefix + "." + k + } + paths[path] = struct{}{} + collectJSONKeyPaths(path, val, paths) } case []any: for _, val := range t { - collectJSONKeys(val, keys) + collectJSONKeyPaths(prefix, val, paths) } } } @@ -331,7 +371,9 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex "Remove them or update them to the current schema. See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", strings.Join(dropped, ", "), ) - if slices.Contains(dropped, "effort") { + if slices.ContainsFunc(dropped, func(p string) bool { + return p == "effort" || strings.HasSuffix(p, ".effort") + }) { detail += " Reasoning effort is now configured with the top-level \"reasoning_effort\" object ({\"default\":..., \"max\":...}) instead of provider_options.*.effort." } resp.Diagnostics.AddAttributeError( @@ -340,12 +382,3 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex detail, ) } - -func sliceContains(s []string, target string) (int, bool) { - for i, v := range s { - if v == target { - return i, true - } - } - return 0, false -} diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 004b66f..023e736 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -408,11 +408,34 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { require.Equal(t, []string{"nonsense"}, dropped) }) - t.Run("unknown nested key is reported", func(t *testing.T) { + t.Run("unknown nested key is reported by path", func(t *testing.T) { t.Parallel() dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"anthropic":{"bogus_setting":"x","thinking":{"budget_tokens":4096}}}}`) require.NoError(t, err) - require.Equal(t, []string{"bogus_setting"}, dropped) + require.Equal(t, []string{"provider_options.anthropic.bogus_setting"}, dropped) + }) + + t.Run("valid name does not mask a dropped occurrence elsewhere", func(t *testing.T) { + t.Parallel() + // The top-level name survives, but the misplaced nested one is dropped. + dropped, err := agentsModelConfigDroppedKeys(`{"max_output_tokens":8192,"provider_options":{"anthropic":{"max_output_tokens":4096}}}`) + require.NoError(t, err) + require.Equal(t, []string{"provider_options.anthropic.max_output_tokens"}, dropped) + }) + + t.Run("legacy top-level pricing keys are not dropped", func(t *testing.T) { + t.Parallel() + // The SDK folds these under "cost"; that relocation is not a drop. + dropped, err := agentsModelConfigDroppedKeys(`{"input_price_per_million_tokens":"3","output_price_per_million_tokens":"15"}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("fully unknown object reports only the parent path", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":1}}`) + require.NoError(t, err) + require.Equal(t, []string{"bogus_block"}, dropped) }) t.Run("multiple unknown keys are reported sorted", func(t *testing.T) { From 986c4692665383d638bbfca8c8012029de8220ba Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 06:54:20 +0000 Subject: [PATCH 03/11] fix(internal/provider): point dropped-effort hint at pinned SDK paths --- internal/provider/agents_model_config.go | 7 +++-- .../provider/agents_model_resource_test.go | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 20bcf5b..ffd2c96 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -372,9 +372,12 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex strings.Join(dropped, ", "), ) if slices.ContainsFunc(dropped, func(p string) bool { - return p == "effort" || strings.HasSuffix(p, ".effort") + last := p[strings.LastIndex(p, ".")+1:] + return last == "effort" || last == "reasoning_effort" }) { - detail += " Reasoning effort is now configured with the top-level \"reasoning_effort\" object ({\"default\":..., \"max\":...}) instead of provider_options.*.effort." + detail += " Reasoning effort is configured per provider: \"provider_options.openai.reasoning_effort\" (also Azure), " + + "\"provider_options.anthropic.effort\" (also Bedrock), \"provider_options.openaicompat.reasoning_effort\", " + + "or \"reasoning.effort\" under \"provider_options.openrouter\" / \"provider_options.vercel\"." } resp.Diagnostics.AddAttributeError( req.Path, diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 023e736..035349e 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -450,6 +450,21 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { _, err := agentsModelConfigDroppedKeys(`{`) require.Error(t, err) }) + + t.Run("hint-recommended effort paths are accepted by the pinned SDK", func(t *testing.T) { + t.Parallel() + for _, raw := range []string{ + `{"provider_options":{"openai":{"reasoning_effort":"high"}}}`, + `{"provider_options":{"anthropic":{"effort":"high"}}}`, + `{"provider_options":{"openaicompat":{"reasoning_effort":"high"}}}`, + `{"provider_options":{"openrouter":{"reasoning":{"effort":"high"}}}}`, + `{"provider_options":{"vercel":{"reasoning":{"effort":"high"}}}}`, + } { + dropped, err := agentsModelConfigDroppedKeys(raw) + require.NoError(t, err, raw) + require.Empty(t, dropped, raw) + } + }) } func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { @@ -477,6 +492,21 @@ func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { require.Contains(t, diags[0].Detail(), "bogus_setting") }) + t.Run("misplaced provider effort points at the supported path", func(t *testing.T) { + t.Parallel() + diags := validate(t, types.StringValue(`{"provider_options":{"openai":{"effort":"high"}}}`)) + require.True(t, diags.HasError()) + require.Contains(t, diags[0].Detail(), "provider_options.openai.effort") + require.Contains(t, diags[0].Detail(), "provider_options.openai.reasoning_effort") + }) + + t.Run("top-level reasoning_effort triggers the hint", func(t *testing.T) { + t.Parallel() + diags := validate(t, types.StringValue(`{"reasoning_effort":{"default":"medium","max":"high"}}`)) + require.True(t, diags.HasError()) + require.Contains(t, diags[0].Detail(), "Reasoning effort is configured per provider") + }) + t.Run("null is allowed", func(t *testing.T) { t.Parallel() require.False(t, validate(t, types.StringNull()).HasError()) From 4bc9c6aca5270fab0bc3b2d379e5835696798586 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 07:20:48 +0000 Subject: [PATCH 04/11] fix(internal/provider): ignore null-valued model_config keys when detecting drops --- internal/provider/agents_model_config.go | 8 ++++- .../provider/agents_model_resource_test.go | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index ffd2c96..e9cc9cd 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -304,7 +304,10 @@ func agentsModelConfigHasDroppedAncestor(path string, dropped map[string]struct{ } // agentsModelConfigKeyPaths collects the dotted path of every object key in a -// JSON document. Arrays are traversed without an index segment. +// JSON document. Arrays are traversed without an index segment. Keys whose value +// is null are skipped: jsonencode of an optional/null variable emits them and +// the SDK unmarshals null into an unset pointer, so a null carries no setting +// and its omission from the canonical output is not a dropped configuration. func agentsModelConfigKeyPaths(raw string) (map[string]struct{}, error) { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() @@ -321,6 +324,9 @@ func collectJSONKeyPaths(prefix string, v any, paths map[string]struct{}) { switch t := v.(type) { case map[string]any: for k, val := range t { + if val == nil { + continue + } path := k if prefix != "" { path = prefix + "." + k diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 035349e..64af496 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -445,6 +445,35 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { require.Equal(t, []string{"alpha", "zeta"}, dropped) }) + t.Run("null-valued known key is not dropped", func(t *testing.T) { + t.Parallel() + // jsonencode of an optional/null variable renders temperature = null. + dropped, err := agentsModelConfigDroppedKeys(`{"temperature":null,"top_p":0.9}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("nested null-valued known key is not dropped", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"openai":{"reasoning_effort":null}}}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("null-valued unknown key is not dropped", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"bogus":null}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("non-null unknown block with only null children is reported", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":null}}`) + require.NoError(t, err) + require.Equal(t, []string{"bogus_block"}, dropped) + }) + t.Run("invalid json returns error", func(t *testing.T) { t.Parallel() _, err := agentsModelConfigDroppedKeys(`{`) From 9ff5a4229305b5d63ae637102ef8766ad2c3b1a8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 07:39:32 +0000 Subject: [PATCH 05/11] fix(internal/provider): detect legacy pricing keys shadowed by cost --- internal/provider/agents_model_config.go | 13 ++++++++++- .../provider/agents_model_resource_test.go | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index e9cc9cd..33c77f3 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -260,6 +260,17 @@ func agentsModelConfigDroppedKeys(raw string) ([]string, error) { dropped[p] = struct{}{} } } + // A legacy pricing key and its current cost. twin both survive the path + // diff (the SDK folds the legacy value into an unset cost field), but when + // cost. is already set the SDK keeps the nested value and discards the + // legacy one. Path survival hides that, so flag the shadowed legacy key. + for legacy := range agentsModelConfigLegacyPricingKeys { + _, hasLegacy := inputPaths[legacy] + _, hasNested := inputPaths["cost."+legacy] + if hasLegacy && hasNested { + dropped[legacy] = struct{}{} + } + } var out []string for p := range dropped { if !agentsModelConfigHasDroppedAncestor(p, dropped) { @@ -373,7 +384,7 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex return } detail := fmt.Sprintf( - "These model_config settings are not part of Coder's chat model config schema and would be silently discarded: %s. "+ + "These model_config settings would be silently discarded by Coder: %s. "+ "Remove them or update them to the current schema. See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", strings.Join(dropped, ", "), ) diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 64af496..4d0686d 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -431,6 +431,22 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { require.Empty(t, dropped) }) + t.Run("legacy pricing key shadowed by cost is reported", func(t *testing.T) { + t.Parallel() + // The SDK keeps the nested cost value and discards the legacy one, so the + // legacy override is silently lost even though its path survives. + dropped, err := agentsModelConfigDroppedKeys(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"999"}`) + require.NoError(t, err) + require.Equal(t, []string{"input_price_per_million_tokens"}, dropped) + }) + + t.Run("legacy pricing key alone is not dropped", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"input_price_per_million_tokens":"3"}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + t.Run("fully unknown object reports only the parent path", func(t *testing.T) { t.Parallel() dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":1}}`) @@ -521,6 +537,13 @@ func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { require.Contains(t, diags[0].Detail(), "bogus_setting") }) + t.Run("legacy pricing key shadowed by cost is rejected and named", func(t *testing.T) { + t.Parallel() + diags := validate(t, types.StringValue(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"999"}`)) + require.True(t, diags.HasError()) + require.Contains(t, diags[0].Detail(), "input_price_per_million_tokens") + }) + t.Run("misplaced provider effort points at the supported path", func(t *testing.T) { t.Parallel() diags := validate(t, types.StringValue(`{"provider_options":{"openai":{"effort":"high"}}}`)) From 5ef88fb59b6e45cf04be48be0ba9cf44e9953740 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 08:01:22 +0000 Subject: [PATCH 06/11] fix(internal/provider): ignore empty recognized collections when detecting drops --- internal/provider/agents_model_config.go | 52 ++++++++++++++----- .../provider/agents_model_resource_test.go | 28 ++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 33c77f3..e2e38c2 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -242,7 +242,11 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re // dropped occurrence of the same name elsewhere. Only the shallowest dropped // path per subtree is returned. func agentsModelConfigDroppedKeys(raw string) ([]string, error) { - inputPaths, err := agentsModelConfigKeyPaths(raw) + // Skip content-free values in the input (omitempty will discard them) but + // keep everything in the canonical output: an empty {} there marks a + // recognized container that survived, and dropping it would make its + // surviving parent look discarded. + inputPaths, err := agentsModelConfigKeyPaths(raw, true) if err != nil { return nil, err } @@ -250,7 +254,7 @@ func agentsModelConfigDroppedKeys(raw string) ([]string, error) { if err != nil { return nil, err } - outputPaths, err := agentsModelConfigKeyPaths(canonical) + outputPaths, err := agentsModelConfigKeyPaths(canonical, false) if err != nil { return nil, err } @@ -315,11 +319,14 @@ func agentsModelConfigHasDroppedAncestor(path string, dropped map[string]struct{ } // agentsModelConfigKeyPaths collects the dotted path of every object key in a -// JSON document. Arrays are traversed without an index segment. Keys whose value -// is null are skipped: jsonencode of an optional/null variable emits them and -// the SDK unmarshals null into an unset pointer, so a null carries no setting -// and its omission from the canonical output is not a dropped configuration. -func agentsModelConfigKeyPaths(raw string) (map[string]struct{}, error) { +// JSON document. Arrays are traversed without an index segment. When +// skipContentFree is set, keys whose value is content-free (see +// agentsModelConfigIsContentFree) are omitted; this is used for the input +// document only, since omitempty strips those from the canonical output whether +// or not the key is recognized, so their absence there is not evidence of a +// dropped setting. The canonical output is collected with skipContentFree false +// so an empty {} still records the recognized container that produced it. +func agentsModelConfigKeyPaths(raw string, skipContentFree bool) (map[string]struct{}, error) { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() var v any @@ -327,15 +334,15 @@ func agentsModelConfigKeyPaths(raw string) (map[string]struct{}, error) { return nil, err } paths := map[string]struct{}{} - collectJSONKeyPaths("", v, paths) + collectJSONKeyPaths("", v, paths, skipContentFree) return paths, nil } -func collectJSONKeyPaths(prefix string, v any, paths map[string]struct{}) { +func collectJSONKeyPaths(prefix string, v any, paths map[string]struct{}, skipContentFree bool) { switch t := v.(type) { case map[string]any: for k, val := range t { - if val == nil { + if skipContentFree && agentsModelConfigIsContentFree(val) { continue } path := k @@ -343,15 +350,36 @@ func collectJSONKeyPaths(prefix string, v any, paths map[string]struct{}) { path = prefix + "." + k } paths[path] = struct{}{} - collectJSONKeyPaths(path, val, paths) + collectJSONKeyPaths(path, val, paths, skipContentFree) } case []any: for _, val := range t { - collectJSONKeyPaths(prefix, val, paths) + collectJSONKeyPaths(prefix, val, paths, skipContentFree) } } } +// agentsModelConfigIsContentFree reports whether a decoded JSON value carries no +// configuration: JSON null, or an empty array/object (which jsonencode of an +// optional/empty variable readily produces). The SDK's fields use omitempty, so +// it drops these from the canonical output whether or not the key is recognized, +// making their absence useless as a signal for a dropped setting. Scalars — +// including 0, false, and "" — are never content-free: recognized ones survive +// canonicalization through pointer fields, and unrecognized ones must still be +// reported. +func agentsModelConfigIsContentFree(v any) bool { + switch t := v.(type) { + case nil: + return true + case []any: + return len(t) == 0 + case map[string]any: + return len(t) == 0 + default: + return false + } +} + // agentsModelConfigNoDroppedKeysValidator rejects a model_config whose keys are // silently discarded when Coder canonicalizes it. Without this a schema change // in codersdk.ChatModelCallConfig (a removed or renamed field) would drop the diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 4d0686d..fbba9ae 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -483,6 +483,29 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { require.Empty(t, dropped) }) + t.Run("empty recognized array is not dropped", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"openai":{"allowed_domains":[]}}}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("empty recognized map is not dropped", func(t *testing.T) { + t.Parallel() + dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"vercel":{"logit_bias":{}}}}`) + require.NoError(t, err) + require.Empty(t, dropped) + }) + + t.Run("unknown scalar zero is still reported", func(t *testing.T) { + t.Parallel() + // 0/false/"" carry configuration, so an unknown key with a scalar zero + // value must not slip through the content-free skip. + dropped, err := agentsModelConfigDroppedKeys(`{"bogus":0}`) + require.NoError(t, err) + require.Equal(t, []string{"bogus"}, dropped) + }) + t.Run("non-null unknown block with only null children is reported", func(t *testing.T) { t.Parallel() dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":null}}`) @@ -559,6 +582,11 @@ func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { require.Contains(t, diags[0].Detail(), "Reasoning effort is configured per provider") }) + t.Run("empty recognized collection is allowed", func(t *testing.T) { + t.Parallel() + require.False(t, validate(t, types.StringValue(`{"provider_options":{"openai":{"allowed_domains":[]}}}`)).HasError()) + }) + t.Run("null is allowed", func(t *testing.T) { t.Parallel() require.False(t, validate(t, types.StringNull()).HasError()) From af7bae01085293425987c5162c7e384ded9a8258 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 09:06:10 +0000 Subject: [PATCH 07/11] refactor(internal/provider): probe the SDK for dropped model_config keys instead of hardcoding schema --- internal/provider/agents_model_config.go | 183 +++++++----------- .../provider/agents_model_resource_test.go | 69 ++----- 2 files changed, 80 insertions(+), 172 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index e2e38c2..582d26b 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "slices" "sort" "strings" @@ -236,137 +235,96 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re } } -// agentsModelConfigDroppedKeys returns the dotted key paths in raw that the SDK -// silently discards when canonicalizing through codersdk.ChatModelCallConfig. -// Comparing paths (not bare names) means a key valid in one place can't mask a -// dropped occurrence of the same name elsewhere. Only the shallowest dropped -// path per subtree is returned. +// agentsModelConfigDroppedKeys returns the dotted paths in raw that Coder +// silently discards when canonicalizing through codersdk.ChatModelCallConfig. A +// key is dropped iff removing it leaves the canonical output unchanged, so the +// SDK is the sole oracle and nothing here restates its schema. func agentsModelConfigDroppedKeys(raw string) ([]string, error) { - // Skip content-free values in the input (omitempty will discard them) but - // keep everything in the canonical output: an empty {} there marks a - // recognized container that survived, and dropping it would make its - // surviving parent look discarded. - inputPaths, err := agentsModelConfigKeyPaths(raw, true) - if err != nil { + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + var doc any + if err := dec.Decode(&doc); err != nil { return nil, err } - canonical, err := agentsModelConfigCanonicalJSON(raw) - if err != nil { + root, ok := doc.(map[string]any) + if !ok { + _, err := agentsModelConfigCanonicalJSON(raw) return nil, err } - outputPaths, err := agentsModelConfigKeyPaths(canonical, false) + baseline, err := agentsModelConfigCanonicalDoc(doc) if err != nil { return nil, err } dropped := map[string]struct{}{} - for p := range inputPaths { - if _, ok := outputPaths[agentsModelConfigCanonicalPath(p)]; !ok { - dropped[p] = struct{}{} - } - } - // A legacy pricing key and its current cost. twin both survive the path - // diff (the SDK folds the legacy value into an unset cost field), but when - // cost. is already set the SDK keeps the nested value and discards the - // legacy one. Path survival hides that, so flag the shadowed legacy key. - for legacy := range agentsModelConfigLegacyPricingKeys { - _, hasLegacy := inputPaths[legacy] - _, hasNested := inputPaths["cost."+legacy] - if hasLegacy && hasNested { - dropped[legacy] = struct{}{} - } + if err := agentsModelConfigProbeDropped(doc, root, "", baseline, dropped); err != nil { + return nil, err } var out []string for p := range dropped { - if !agentsModelConfigHasDroppedAncestor(p, dropped) { - out = append(out, p) - } + out = append(out, p) } sort.Strings(out) return out, nil } -// agentsModelConfigLegacyPricingKeys are the pre-"cost" top-level pricing keys -// the SDK still accepts and folds under "cost" on read. This relocation is the -// only reason agentsModelConfigCanonicalPath exists. -var agentsModelConfigLegacyPricingKeys = map[string]struct{}{ - "input_price_per_million_tokens": {}, - "output_price_per_million_tokens": {}, - "cache_read_price_per_million_tokens": {}, - "cache_write_price_per_million_tokens": {}, -} - -// agentsModelConfigCanonicalPath maps a legacy top-level pricing key to its -// post-canonicalization path under "cost", leaving every other path unchanged. -func agentsModelConfigCanonicalPath(path string) string { - if _, ok := agentsModelConfigLegacyPricingKeys[path]; ok { - return "cost." + path +func agentsModelConfigCanonicalDoc(doc any) (string, error) { + encoded, err := json.Marshal(doc) + if err != nil { + return "", err } - return path + return agentsModelConfigCanonicalJSON(string(encoded)) } -// agentsModelConfigHasDroppedAncestor reports whether any parent of path is -// itself dropped. -func agentsModelConfigHasDroppedAncestor(path string, dropped map[string]struct{}) bool { - for i := 0; i < len(path); i++ { - if path[i] != '.' { +// agentsModelConfigProbeDropped marks the path of every key whose removal leaves +// the canonical output unchanged, then recurses into surviving containers. +func agentsModelConfigProbeDropped(doc any, node map[string]any, prefix, baseline string, dropped map[string]struct{}) error { + // Snapshot keys so mutating node during iteration is safe. + keys := make([]string, 0, len(node)) + for k := range node { + keys = append(keys, k) + } + for _, k := range keys { + val := node[k] + if agentsModelConfigIsContentFree(val) { continue } - if _, ok := dropped[path[:i]]; ok { - return true + path := k + if prefix != "" { + path = prefix + "." + k } - } - return false -} - -// agentsModelConfigKeyPaths collects the dotted path of every object key in a -// JSON document. Arrays are traversed without an index segment. When -// skipContentFree is set, keys whose value is content-free (see -// agentsModelConfigIsContentFree) are omitted; this is used for the input -// document only, since omitempty strips those from the canonical output whether -// or not the key is recognized, so their absence there is not evidence of a -// dropped setting. The canonical output is collected with skipContentFree false -// so an empty {} still records the recognized container that produced it. -func agentsModelConfigKeyPaths(raw string, skipContentFree bool) (map[string]struct{}, error) { - dec := json.NewDecoder(strings.NewReader(raw)) - dec.UseNumber() - var v any - if err := dec.Decode(&v); err != nil { - return nil, err - } - paths := map[string]struct{}{} - collectJSONKeyPaths("", v, paths, skipContentFree) - return paths, nil -} - -func collectJSONKeyPaths(prefix string, v any, paths map[string]struct{}, skipContentFree bool) { - switch t := v.(type) { - case map[string]any: - for k, val := range t { - if skipContentFree && agentsModelConfigIsContentFree(val) { - continue + delete(node, k) + probe, err := agentsModelConfigCanonicalDoc(doc) + node[k] = val + if err != nil { + return err + } + if probe == baseline { + dropped[path] = struct{}{} + continue + } + switch t := val.(type) { + case map[string]any: + if err := agentsModelConfigProbeDropped(doc, t, path, baseline, dropped); err != nil { + return err } - path := k - if prefix != "" { - path = prefix + "." + k + case []any: + for _, el := range t { + if m, ok := el.(map[string]any); ok { + if err := agentsModelConfigProbeDropped(doc, m, path, baseline, dropped); err != nil { + return err + } + } } - paths[path] = struct{}{} - collectJSONKeyPaths(path, val, paths, skipContentFree) - } - case []any: - for _, val := range t { - collectJSONKeyPaths(prefix, val, paths, skipContentFree) } } + return nil } -// agentsModelConfigIsContentFree reports whether a decoded JSON value carries no -// configuration: JSON null, or an empty array/object (which jsonencode of an -// optional/empty variable readily produces). The SDK's fields use omitempty, so -// it drops these from the canonical output whether or not the key is recognized, -// making their absence useless as a signal for a dropped setting. Scalars — -// including 0, false, and "" — are never content-free: recognized ones survive -// canonicalization through pointer fields, and unrecognized ones must still be -// reported. +// agentsModelConfigIsContentFree reports whether a decoded JSON value is null or +// an empty array/object. The SDK's omitempty fields drop these regardless of +// whether the key is recognized, so removing one never changes the canonical +// output and the probe can't judge it. Scalars (including 0, false, "") carry +// config and are never content-free. func agentsModelConfigIsContentFree(v any) bool { switch t := v.(type) { case nil: @@ -411,22 +369,13 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex if len(dropped) == 0 { return } - detail := fmt.Sprintf( - "These model_config settings would be silently discarded by Coder: %s. "+ - "Remove them or update them to the current schema. See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", - strings.Join(dropped, ", "), - ) - if slices.ContainsFunc(dropped, func(p string) bool { - last := p[strings.LastIndex(p, ".")+1:] - return last == "effort" || last == "reasoning_effort" - }) { - detail += " Reasoning effort is configured per provider: \"provider_options.openai.reasoning_effort\" (also Azure), " + - "\"provider_options.anthropic.effort\" (also Bedrock), \"provider_options.openaicompat.reasoning_effort\", " + - "or \"reasoning.effort\" under \"provider_options.openrouter\" / \"provider_options.vercel\"." - } resp.Diagnostics.AddAttributeError( req.Path, "Unrecognized model_config settings", - detail, + fmt.Sprintf( + "These model_config settings would be silently discarded by Coder: %s. "+ + "Remove them or update them to the current schema. See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", + strings.Join(dropped, ", "), + ), ) } diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index fbba9ae..1d13eec 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -417,36 +417,25 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { t.Run("valid name does not mask a dropped occurrence elsewhere", func(t *testing.T) { t.Parallel() - // The top-level name survives, but the misplaced nested one is dropped. dropped, err := agentsModelConfigDroppedKeys(`{"max_output_tokens":8192,"provider_options":{"anthropic":{"max_output_tokens":4096}}}`) require.NoError(t, err) require.Equal(t, []string{"provider_options.anthropic.max_output_tokens"}, dropped) }) - t.Run("legacy top-level pricing keys are not dropped", func(t *testing.T) { + t.Run("legacy top-level pricing keys are folded under cost, not dropped", func(t *testing.T) { t.Parallel() - // The SDK folds these under "cost"; that relocation is not a drop. dropped, err := agentsModelConfigDroppedKeys(`{"input_price_per_million_tokens":"3","output_price_per_million_tokens":"15"}`) require.NoError(t, err) require.Empty(t, dropped) }) - t.Run("legacy pricing key shadowed by cost is reported", func(t *testing.T) { + t.Run("legacy pricing key shadowed by a differing cost is reported", func(t *testing.T) { t.Parallel() - // The SDK keeps the nested cost value and discards the legacy one, so the - // legacy override is silently lost even though its path survives. dropped, err := agentsModelConfigDroppedKeys(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"999"}`) require.NoError(t, err) require.Equal(t, []string{"input_price_per_million_tokens"}, dropped) }) - t.Run("legacy pricing key alone is not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"input_price_per_million_tokens":"3"}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - t.Run("fully unknown object reports only the parent path", func(t *testing.T) { t.Parallel() dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":1}}`) @@ -463,7 +452,6 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { t.Run("null-valued known key is not dropped", func(t *testing.T) { t.Parallel() - // jsonencode of an optional/null variable renders temperature = null. dropped, err := agentsModelConfigDroppedKeys(`{"temperature":null,"top_p":0.9}`) require.NoError(t, err) require.Empty(t, dropped) @@ -499,8 +487,6 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { t.Run("unknown scalar zero is still reported", func(t *testing.T) { t.Parallel() - // 0/false/"" carry configuration, so an unknown key with a scalar zero - // value must not slip through the content-free skip. dropped, err := agentsModelConfigDroppedKeys(`{"bogus":0}`) require.NoError(t, err) require.Equal(t, []string{"bogus"}, dropped) @@ -519,19 +505,19 @@ func TestAgentsModelConfigDroppedKeys(t *testing.T) { require.Error(t, err) }) - t.Run("hint-recommended effort paths are accepted by the pinned SDK", func(t *testing.T) { + t.Run("unknown provider block is reported at its path", func(t *testing.T) { t.Parallel() - for _, raw := range []string{ - `{"provider_options":{"openai":{"reasoning_effort":"high"}}}`, - `{"provider_options":{"anthropic":{"effort":"high"}}}`, - `{"provider_options":{"openaicompat":{"reasoning_effort":"high"}}}`, - `{"provider_options":{"openrouter":{"reasoning":{"effort":"high"}}}}`, - `{"provider_options":{"vercel":{"reasoning":{"effort":"high"}}}}`, - } { - dropped, err := agentsModelConfigDroppedKeys(raw) - require.NoError(t, err, raw) - require.Empty(t, dropped, raw) - } + dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"bogus_provider":{"foo":"bar"}}}`) + require.NoError(t, err) + require.Equal(t, []string{"provider_options.bogus_provider"}, dropped) + }) + + t.Run("equal-value legacy and cost pricing are both reported", func(t *testing.T) { + t.Parallel() + // Either is removable without changing the result, so the redundancy reports from both sides. + dropped, err := agentsModelConfigDroppedKeys(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"3"}`) + require.NoError(t, err) + require.Equal(t, []string{"cost", "input_price_per_million_tokens"}, dropped) }) } @@ -560,33 +546,6 @@ func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { require.Contains(t, diags[0].Detail(), "bogus_setting") }) - t.Run("legacy pricing key shadowed by cost is rejected and named", func(t *testing.T) { - t.Parallel() - diags := validate(t, types.StringValue(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"999"}`)) - require.True(t, diags.HasError()) - require.Contains(t, diags[0].Detail(), "input_price_per_million_tokens") - }) - - t.Run("misplaced provider effort points at the supported path", func(t *testing.T) { - t.Parallel() - diags := validate(t, types.StringValue(`{"provider_options":{"openai":{"effort":"high"}}}`)) - require.True(t, diags.HasError()) - require.Contains(t, diags[0].Detail(), "provider_options.openai.effort") - require.Contains(t, diags[0].Detail(), "provider_options.openai.reasoning_effort") - }) - - t.Run("top-level reasoning_effort triggers the hint", func(t *testing.T) { - t.Parallel() - diags := validate(t, types.StringValue(`{"reasoning_effort":{"default":"medium","max":"high"}}`)) - require.True(t, diags.HasError()) - require.Contains(t, diags[0].Detail(), "Reasoning effort is configured per provider") - }) - - t.Run("empty recognized collection is allowed", func(t *testing.T) { - t.Parallel() - require.False(t, validate(t, types.StringValue(`{"provider_options":{"openai":{"allowed_domains":[]}}}`)).HasError()) - }) - t.Run("null is allowed", func(t *testing.T) { t.Parallel() require.False(t, validate(t, types.StringNull()).HasError()) From 55e91fa9cfe07dc9d976433d883e573f1c7b5abd Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 10:02:31 +0000 Subject: [PATCH 08/11] docs(internal/provider): clarify the provider drops unrecognized model_config keys --- internal/provider/agents_model_config.go | 37 ++++++++++++------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 582d26b..1ba5cd1 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -137,11 +137,12 @@ func agentsModelConfigCanonicalJSON(raw string) (string, error) { // 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. +// change is key order. The model_config read back from Coder is re-encoded from +// codersdk.ChatModelCallConfig in struct 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() @@ -235,10 +236,11 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re } } -// agentsModelConfigDroppedKeys returns the dotted paths in raw that Coder -// silently discards when canonicalizing through codersdk.ChatModelCallConfig. A -// key is dropped iff removing it leaves the canonical output unchanged, so the -// SDK is the sole oracle and nothing here restates its schema. +// agentsModelConfigDroppedKeys returns the dotted paths in raw that the provider +// silently drops when decoding into codersdk.ChatModelCallConfig, since +// encoding/json ignores keys with no matching struct field. A key is dropped iff +// removing it leaves the canonical output unchanged, so the SDK type is the sole +// oracle and nothing here restates its schema. func agentsModelConfigDroppedKeys(raw string) ([]string, error) { dec := json.NewDecoder(strings.NewReader(raw)) dec.UseNumber() @@ -338,18 +340,16 @@ func agentsModelConfigIsContentFree(v any) bool { } } -// agentsModelConfigNoDroppedKeysValidator rejects a model_config whose keys are -// silently discarded when Coder canonicalizes it. Without this a schema change -// in codersdk.ChatModelCallConfig (a removed or renamed field) would drop the -// user's setting with no plan-time error, leaving Terraform to consider the -// resulting state semantically equal. Erroring at plan time forces users to -// migrate to the current schema instead of losing configuration. +// agentsModelConfigNoDroppedKeysValidator rejects a model_config whose keys the +// provider would silently drop (see agentsModelConfigDroppedKeys). Without it a +// removed or renamed field in codersdk.ChatModelCallConfig would drop the user's +// setting with no plan-time error, and semantic equality would hide the loss. type agentsModelConfigNoDroppedKeysValidator struct{} var _ validator.String = agentsModelConfigNoDroppedKeysValidator{} func (v agentsModelConfigNoDroppedKeysValidator) Description(_ context.Context) string { - return "model_config must not contain settings that Coder does not recognize and would silently discard." + return "model_config must only contain settings from the Coder chat model config schema this provider was built against; unrecognized settings would be silently dropped." } func (v agentsModelConfigNoDroppedKeysValidator) MarkdownDescription(ctx context.Context) string { @@ -373,8 +373,9 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex req.Path, "Unrecognized model_config settings", fmt.Sprintf( - "These model_config settings would be silently discarded by Coder: %s. "+ - "Remove them or update them to the current schema. See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", + "These model_config settings are not part of the Coder chat model config schema this provider was built against, so they would be silently dropped before reaching Coder: %s. "+ + "If a setting is valid for your Coder deployment, upgrade the provider to a version built against your Coder release; otherwise remove it or fix the field name. "+ + "See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", strings.Join(dropped, ", "), ), ) From 322d3dc2968c65b8df46cf220f6fff3a2c5aa5bb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 11:20:20 +0000 Subject: [PATCH 09/11] refactor(internal/provider): use codersdk UnmarshalStrict to reject dropped model_config settings Replaces the removal-probe drop detection with codersdk.ChatModelCallConfig.UnmarshalStrict (coder/coder#27187), pinning the SDK to that PR's commit. The strict decoder shares the lenient path's aux struct, so the SDK stays the sole oracle with no schema knowledge in the provider. --- go.mod | 17 +- go.sum | 34 ++-- internal/provider/agents_model_config.go | 144 +++------------ .../provider/agents_model_resource_test.go | 165 ++---------------- 4 files changed, 57 insertions(+), 303 deletions(-) diff --git a/go.mod b/go.mod index 02e2c66..91a2b79 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.4 require ( cdr.dev/slog/v3 v3.1.0 - github.com/coder/coder/v2 v2.34.0-rc.0.0.20260701204415-db7f4438b4ad + github.com/coder/coder/v2 v2.34.0-rc.0.0.20260713111656-06107768a8d6 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.15.0 github.com/coder/websocket v1.8.15 @@ -53,8 +53,9 @@ require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect - github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -86,7 +87,7 @@ require ( github.com/ebitengine/purego v0.10.1 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-chi/chi/v5 v5.2.4 // indirect + github.com/go-chi/chi/v5 v5.3.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -121,7 +122,7 @@ require ( github.com/imdario/mergo v0.3.16 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -188,7 +189,7 @@ require ( github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - github.com/zclconf/go-cty v1.18.1 // indirect + github.com/zclconf/go-cty v1.19.0 // indirect github.com/zeebo/errs v1.4.0 // indirect go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect go.nhat.io/otelsql v0.16.0 // indirect @@ -217,14 +218,14 @@ require ( golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 // indirect gopkg.in/ini.v1 v1.67.3 // indirect diff --git a/go.sum b/go.sum index 4ebef93..63a2074 100644 --- a/go.sum +++ b/go.sum @@ -76,10 +76,12 @@ github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUS github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/apparentlymart/go-textseg/v17 v17.0.1 h1:bpMXRgQ5cEoRNuQke1a80/Nl6w3G5eoIbWo9f3gXkAs= +github.com/apparentlymart/go-textseg/v17 v17.0.1/go.mod h1:fa8X4jgGeevslICIY6LcdjkSecWnXmYd9Lk34z/VxZs= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= -github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -125,8 +127,8 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coder/coder/v2 v2.34.0-rc.0.0.20260701204415-db7f4438b4ad h1:dVCql8eMM+GBtPmIvdY1hPvONY234fmeZjNLYp5+IVQ= -github.com/coder/coder/v2 v2.34.0-rc.0.0.20260701204415-db7f4438b4ad/go.mod h1:sJc0Y7Rd2kzqjdCMwfrebST+qPJprLub3+TVS7jXHQo= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260713111656-06107768a8d6 h1:zEKALHu+grAlyUJejp6XZoRGkvOQyWYIfY0WEWVXNy8= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260713111656-06107768a8d6/go.mod h1:imH39Jw6JBYWzFZtLx3juwWPhx+KxDqL3oPALrAh/U0= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= @@ -182,8 +184,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= -github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= @@ -313,8 +315,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -532,8 +534,8 @@ github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUei github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= -github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= +github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= +github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= @@ -695,8 +697,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -723,10 +725,10 @@ google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad h1:cYL1DPJAQr4JMvh google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:cVHIikDNAdx8ISZeW+2rYkEMf3xn0GSaBYmVnWXQBUo= google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw= google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/internal/provider/agents_model_config.go b/internal/provider/agents_model_config.go index 1ba5cd1..f42bd99 100644 --- a/internal/provider/agents_model_config.go +++ b/internal/provider/agents_model_config.go @@ -5,8 +5,6 @@ import ( "context" "encoding/json" "fmt" - "sort" - "strings" "github.com/coder/coder/v2/codersdk" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" @@ -236,114 +234,11 @@ func (v agentsModelConfigNotEmptyValidator) ValidateString(_ context.Context, re } } -// agentsModelConfigDroppedKeys returns the dotted paths in raw that the provider -// silently drops when decoding into codersdk.ChatModelCallConfig, since -// encoding/json ignores keys with no matching struct field. A key is dropped iff -// removing it leaves the canonical output unchanged, so the SDK type is the sole -// oracle and nothing here restates its schema. -func agentsModelConfigDroppedKeys(raw string) ([]string, error) { - dec := json.NewDecoder(strings.NewReader(raw)) - dec.UseNumber() - var doc any - if err := dec.Decode(&doc); err != nil { - return nil, err - } - root, ok := doc.(map[string]any) - if !ok { - _, err := agentsModelConfigCanonicalJSON(raw) - return nil, err - } - baseline, err := agentsModelConfigCanonicalDoc(doc) - if err != nil { - return nil, err - } - dropped := map[string]struct{}{} - if err := agentsModelConfigProbeDropped(doc, root, "", baseline, dropped); err != nil { - return nil, err - } - var out []string - for p := range dropped { - out = append(out, p) - } - sort.Strings(out) - return out, nil -} - -func agentsModelConfigCanonicalDoc(doc any) (string, error) { - encoded, err := json.Marshal(doc) - if err != nil { - return "", err - } - return agentsModelConfigCanonicalJSON(string(encoded)) -} - -// agentsModelConfigProbeDropped marks the path of every key whose removal leaves -// the canonical output unchanged, then recurses into surviving containers. -func agentsModelConfigProbeDropped(doc any, node map[string]any, prefix, baseline string, dropped map[string]struct{}) error { - // Snapshot keys so mutating node during iteration is safe. - keys := make([]string, 0, len(node)) - for k := range node { - keys = append(keys, k) - } - for _, k := range keys { - val := node[k] - if agentsModelConfigIsContentFree(val) { - continue - } - path := k - if prefix != "" { - path = prefix + "." + k - } - delete(node, k) - probe, err := agentsModelConfigCanonicalDoc(doc) - node[k] = val - if err != nil { - return err - } - if probe == baseline { - dropped[path] = struct{}{} - continue - } - switch t := val.(type) { - case map[string]any: - if err := agentsModelConfigProbeDropped(doc, t, path, baseline, dropped); err != nil { - return err - } - case []any: - for _, el := range t { - if m, ok := el.(map[string]any); ok { - if err := agentsModelConfigProbeDropped(doc, m, path, baseline, dropped); err != nil { - return err - } - } - } - } - } - return nil -} - -// agentsModelConfigIsContentFree reports whether a decoded JSON value is null or -// an empty array/object. The SDK's omitempty fields drop these regardless of -// whether the key is recognized, so removing one never changes the canonical -// output and the probe can't judge it. Scalars (including 0, false, "") carry -// config and are never content-free. -func agentsModelConfigIsContentFree(v any) bool { - switch t := v.(type) { - case nil: - return true - case []any: - return len(t) == 0 - case map[string]any: - return len(t) == 0 - default: - return false - } -} - -// agentsModelConfigNoDroppedKeysValidator rejects a model_config whose keys the -// provider would silently drop (see agentsModelConfigDroppedKeys). Without it a -// removed or renamed field in codersdk.ChatModelCallConfig would drop the user's -// setting with no plan-time error, and semantic equality would hide the loss. +// agentsModelConfigNoDroppedKeysValidator rejects a model_config containing +// settings the provider would silently drop when decoding into +// codersdk.ChatModelCallConfig. Without it a removed or renamed field in the +// SDK type would drop the user's setting with no plan-time error, and semantic +// equality would hide the loss. type agentsModelConfigNoDroppedKeysValidator struct{} var _ validator.String = agentsModelConfigNoDroppedKeysValidator{} @@ -362,21 +257,22 @@ func (v agentsModelConfigNoDroppedKeysValidator) ValidateString(_ context.Contex } // Invalid or non-object JSON is left for the custom type and the not-empty // validator to report. - dropped, err := agentsModelConfigDroppedKeys(req.ConfigValue.ValueString()) - if err != nil { + if _, err := agentsModelConfigCanonicalJSON(req.ConfigValue.ValueString()); err != nil { return } - if len(dropped) == 0 { - return + // The lenient decode above succeeded, so a strict failure can only be an + // unknown field. + var config codersdk.ChatModelCallConfig + if err := config.UnmarshalStrict([]byte(req.ConfigValue.ValueString())); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Unrecognized model_config settings", + fmt.Sprintf( + "model_config contains a setting that is not part of the Coder chat model config schema this provider was built against, so it would be silently dropped before reaching Coder: %s. "+ + "If the setting is valid for your Coder deployment, upgrade the provider to a version built against your Coder release; otherwise remove it or fix the field name. "+ + "See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", + err, + ), + ) } - resp.Diagnostics.AddAttributeError( - req.Path, - "Unrecognized model_config settings", - fmt.Sprintf( - "These model_config settings are not part of the Coder chat model config schema this provider was built against, so they would be silently dropped before reaching Coder: %s. "+ - "If a setting is valid for your Coder deployment, upgrade the provider to a version built against your Coder release; otherwise remove it or fix the field name. "+ - "See https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ChatModelCallConfig.", - strings.Join(dropped, ", "), - ), - ) } diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index 1d13eec..dcb3b1d 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -391,141 +391,11 @@ func TestAgentsModelConfigNotEmptyValidator(t *testing.T) { }) } -func TestAgentsModelConfigDroppedKeys(t *testing.T) { - t.Parallel() - - t.Run("recognized config drops nothing", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"max_output_tokens":8192,"temperature":0.7,"top_p":0.9}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("unknown top-level key is reported", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"temperature":0.7,"nonsense":true}`) - require.NoError(t, err) - require.Equal(t, []string{"nonsense"}, dropped) - }) - - t.Run("unknown nested key is reported by path", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"anthropic":{"bogus_setting":"x","thinking":{"budget_tokens":4096}}}}`) - require.NoError(t, err) - require.Equal(t, []string{"provider_options.anthropic.bogus_setting"}, dropped) - }) - - t.Run("valid name does not mask a dropped occurrence elsewhere", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"max_output_tokens":8192,"provider_options":{"anthropic":{"max_output_tokens":4096}}}`) - require.NoError(t, err) - require.Equal(t, []string{"provider_options.anthropic.max_output_tokens"}, dropped) - }) - - t.Run("legacy top-level pricing keys are folded under cost, not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"input_price_per_million_tokens":"3","output_price_per_million_tokens":"15"}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("legacy pricing key shadowed by a differing cost is reported", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"999"}`) - require.NoError(t, err) - require.Equal(t, []string{"input_price_per_million_tokens"}, dropped) - }) - - t.Run("fully unknown object reports only the parent path", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":1}}`) - require.NoError(t, err) - require.Equal(t, []string{"bogus_block"}, dropped) - }) - - t.Run("multiple unknown keys are reported sorted", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"zeta":1,"alpha":2,"temperature":0.7}`) - require.NoError(t, err) - require.Equal(t, []string{"alpha", "zeta"}, dropped) - }) - - t.Run("null-valued known key is not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"temperature":null,"top_p":0.9}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("nested null-valued known key is not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"openai":{"reasoning_effort":null}}}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("null-valued unknown key is not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"bogus":null}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("empty recognized array is not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"openai":{"allowed_domains":[]}}}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("empty recognized map is not dropped", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"vercel":{"logit_bias":{}}}}`) - require.NoError(t, err) - require.Empty(t, dropped) - }) - - t.Run("unknown scalar zero is still reported", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"bogus":0}`) - require.NoError(t, err) - require.Equal(t, []string{"bogus"}, dropped) - }) - - t.Run("non-null unknown block with only null children is reported", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"bogus_block":{"nested":null}}`) - require.NoError(t, err) - require.Equal(t, []string{"bogus_block"}, dropped) - }) - - t.Run("invalid json returns error", func(t *testing.T) { - t.Parallel() - _, err := agentsModelConfigDroppedKeys(`{`) - require.Error(t, err) - }) - - t.Run("unknown provider block is reported at its path", func(t *testing.T) { - t.Parallel() - dropped, err := agentsModelConfigDroppedKeys(`{"provider_options":{"bogus_provider":{"foo":"bar"}}}`) - require.NoError(t, err) - require.Equal(t, []string{"provider_options.bogus_provider"}, dropped) - }) - - t.Run("equal-value legacy and cost pricing are both reported", func(t *testing.T) { - t.Parallel() - // Either is removable without changing the result, so the redundancy reports from both sides. - dropped, err := agentsModelConfigDroppedKeys(`{"cost":{"input_price_per_million_tokens":"3"},"input_price_per_million_tokens":"3"}`) - require.NoError(t, err) - require.Equal(t, []string{"cost", "input_price_per_million_tokens"}, dropped) - }) -} - func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { t.Parallel() v := agentsModelConfigNoDroppedKeysValidator{} - validate := func(t *testing.T, config types.String) diag.Diagnostics { + validate := func(config types.String) diag.Diagnostics { resp := &validator.StringResponse{} v.ValidateString(t.Context(), validator.StringRequest{ Path: path.Root("model_config"), @@ -534,32 +404,17 @@ func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { return resp.Diagnostics } - t.Run("recognized config is allowed", func(t *testing.T) { - t.Parallel() - require.False(t, validate(t, types.StringValue(`{"max_output_tokens":8192}`)).HasError()) - }) + require.False(t, validate(types.StringValue(`{"max_output_tokens":8192,"temperature":0.7}`)).HasError()) - t.Run("dropped key is rejected and named", func(t *testing.T) { - t.Parallel() - diags := validate(t, types.StringValue(`{"temperature":0.7,"bogus_setting":"x"}`)) - require.True(t, diags.HasError()) - require.Contains(t, diags[0].Detail(), "bogus_setting") - }) + diags := validate(types.StringValue(`{"provider_options":{"anthropic":{"bogus_setting":"x"}}}`)) + require.True(t, diags.HasError()) + require.Contains(t, diags[0].Detail(), "bogus_setting") - 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 deferred", func(t *testing.T) { - t.Parallel() - require.False(t, validate(t, types.StringValue(`{`)).HasError()) - }) + require.False(t, validate(types.StringNull()).HasError()) + require.False(t, validate(types.StringUnknown()).HasError()) + // Invalid and non-object JSON are other validators' problem. + require.False(t, validate(types.StringValue(`{`)).HasError()) + require.False(t, validate(types.StringValue(`[1,2]`)).HasError()) } func TestAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { From eb5d2adc5c99f3f8430c160c200379bdd4eaaa60 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 13:28:11 +0000 Subject: [PATCH 10/11] test(internal/provider): refactor dropped-keys validator test into a table test --- .../provider/agents_model_resource_test.go | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/internal/provider/agents_model_resource_test.go b/internal/provider/agents_model_resource_test.go index dcb3b1d..8574caf 100644 --- a/internal/provider/agents_model_resource_test.go +++ b/internal/provider/agents_model_resource_test.go @@ -394,27 +394,35 @@ func TestAgentsModelConfigNotEmptyValidator(t *testing.T) { func TestAgentsModelConfigNoDroppedKeysValidator(t *testing.T) { t.Parallel() - v := agentsModelConfigNoDroppedKeysValidator{} - validate := func(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 + tests := []struct { + name string + config types.String + wantErr string + }{ + {name: "recognized config", config: types.StringValue(`{"max_output_tokens":8192,"temperature":0.7}`)}, + {name: "unknown nested field", config: types.StringValue(`{"provider_options":{"anthropic":{"bogus_setting":"x"}}}`), wantErr: "bogus_setting"}, + {name: "null", config: types.StringNull()}, + {name: "unknown", config: types.StringUnknown()}, + // Invalid and non-object JSON are other validators' problem. + {name: "invalid json", config: types.StringValue(`{`)}, + {name: "non-object json", config: types.StringValue(`[1,2]`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resp := &validator.StringResponse{} + agentsModelConfigNoDroppedKeysValidator{}.ValidateString(t.Context(), validator.StringRequest{ + Path: path.Root("model_config"), + ConfigValue: tt.config, + }, resp) + if tt.wantErr == "" { + require.False(t, resp.Diagnostics.HasError()) + return + } + require.True(t, resp.Diagnostics.HasError()) + require.Contains(t, resp.Diagnostics[0].Detail(), tt.wantErr) + }) } - - require.False(t, validate(types.StringValue(`{"max_output_tokens":8192,"temperature":0.7}`)).HasError()) - - diags := validate(types.StringValue(`{"provider_options":{"anthropic":{"bogus_setting":"x"}}}`)) - require.True(t, diags.HasError()) - require.Contains(t, diags[0].Detail(), "bogus_setting") - - require.False(t, validate(types.StringNull()).HasError()) - require.False(t, validate(types.StringUnknown()).HasError()) - // Invalid and non-object JSON are other validators' problem. - require.False(t, validate(types.StringValue(`{`)).HasError()) - require.False(t, validate(types.StringValue(`[1,2]`)).HasError()) } func TestAgentsModelResourceValidationDefersUnknownConfig(t *testing.T) { From 3364178125b4925a90e69e826165b10e43de62d8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 13 Jul 2026 13:38:01 +0000 Subject: [PATCH 11/11] chore(coderd_agents_model): migrate example, docs, and integration fixtures to top-level reasoning_effort Folds the non-go.mod changes from #386 into this branch, since it already pins an SDK with the reasoning_effort restructure. --- docs/resources/agents_model.md | 5 +- .../resources/coderd_agents_model/resource.tf | 5 +- integration/agents-model-test/main.tf | 22 ++++-- integration/integration_test.go | 70 +++++++++++-------- 4 files changed, 62 insertions(+), 40 deletions(-) diff --git a/docs/resources/agents_model.md b/docs/resources/agents_model.md index 602ea06..8500c6a 100644 --- a/docs/resources/agents_model.md +++ b/docs/resources/agents_model.md @@ -44,9 +44,12 @@ resource "coderd_agents_model" "sonnet" { input_price_per_million_tokens = "3" output_price_per_million_tokens = "15" } + reasoning_effort = { + default = "high" + max = "high" + } 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 62eed4c..41a710b 100644 --- a/examples/resources/coderd_agents_model/resource.tf +++ b/examples/resources/coderd_agents_model/resource.tf @@ -26,9 +26,12 @@ resource "coderd_agents_model" "sonnet" { input_price_per_million_tokens = "3" output_price_per_million_tokens = "15" } + reasoning_effort = { + default = "high" + max = "high" + } provider_options = { anthropic = { - effort = "high" thinking = { budget_tokens = 4096 } } } diff --git a/integration/agents-model-test/main.tf b/integration/agents-model-test/main.tf index c725126..810ed5a 100644 --- a/integration/agents-model-test/main.tf +++ b/integration/agents-model-test/main.tf @@ -42,10 +42,13 @@ resource "coderd_agents_model" "claude_opus" { cache_read_price_per_million_tokens = "0.5" cache_write_price_per_million_tokens = "6.25" } + reasoning_effort = { + default = "high" + max = "high" + } provider_options = { anthropic = { send_reasoning = true - effort = "high" } } }) @@ -63,10 +66,13 @@ resource "coderd_agents_model" "claude_sonnet" { input_price_per_million_tokens = "3" output_price_per_million_tokens = "15" } + reasoning_effort = { + default = "max" + max = "max" + } provider_options = { anthropic = { send_reasoning = true - effort = "max" web_search_enabled = true thinking = { budget_tokens = 16000 @@ -89,10 +95,13 @@ resource "coderd_agents_model" "gpt_xhigh" { output_price_per_million_tokens = "15" cache_read_price_per_million_tokens = "0.25" } + reasoning_effort = { + default = "xhigh" + max = "xhigh" + } provider_options = { openai = { parallel_tool_calls = false - reasoning_effort = "xhigh" reasoning_summary = "detailed" text_verbosity = "high" web_search_enabled = true @@ -110,10 +119,9 @@ resource "coderd_agents_model" "gpt_mini" { compression_threshold = 70 model_config = jsonencode({ - provider_options = { - openai = { - reasoning_effort = "medium" - } + reasoning_effort = { + default = "medium" + max = "medium" } }) } diff --git a/integration/integration_test.go b/integration/integration_test.go index adf2d45..f65d728 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -224,51 +224,59 @@ func TestIntegration(t *testing.T) { "cache_read_price_per_million_tokens": "0.5", "cache_write_price_per_million_tokens": "6.25" }, - "provider_options": { - "anthropic": { - "send_reasoning": true, - "effort": "high" - } + "reasoning_effort": { + "default": "high", + "max": "high" + }, + "provider_options": { + "anthropic": { + "send_reasoning": true + } } - }`}, + }`}, "claude-sonnet-4-6": {"anthropic", `{ "cost": { - "input_price_per_million_tokens": "3", + "input_price_per_million_tokens": "3", "output_price_per_million_tokens": "15" - }, - "provider_options": { + }, + "reasoning_effort": { + "default": "max", + "max": "max" + }, + "provider_options": { "anthropic": { - "send_reasoning": true, - "effort": "max", - "web_search_enabled": true, - "thinking": { - "budget_tokens": 16000 - } + "send_reasoning": true, + "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" + "cost": { + "input_price_per_million_tokens": "2.5", + "output_price_per_million_tokens": "15", + "cache_read_price_per_million_tokens": "0.25" + }, + "reasoning_effort": { + "default": "xhigh", + "max": "xhigh" }, - "provider_options": { - "openai": { - "parallel_tool_calls": false, - "reasoning_effort": "xhigh", - "reasoning_summary": "detailed", + "provider_options": { + "openai": { + "parallel_tool_calls": false, + "reasoning_summary": "detailed", "text_verbosity": "high", - "web_search_enabled": true, + "web_search_enabled": true, "search_context_size": "medium" } } }`}, "gpt-5.4-mini": {"openai", `{ - "provider_options": { - "openai": { - "reasoning_effort": "medium" - } + "reasoning_effort": { + "default": "medium", + "max": "medium" } }`}, }