feat(codersdk): add strict unmarshalling for ChatModelCallConfig (#27187)

Adds `ChatModelCallConfig.UnmarshalStrict`: `UnmarshalJSON` except
unknown fields and trailing data are errors instead of being silently
dropped.

`model_config` is free-form JSON at its edges (Terraform config, API
bodies), so a typo'd setting is dropped with no signal;
coder/terraform-provider-coderd#388 uses this for plan-time validation.
It has to live in codersdk because the custom `UnmarshalJSON` and its
unexported aux struct (which defines the accepted key set, including
legacy pricing aliases) make strict decoding impossible from outside the
package. `UnmarshalJSON` stays lenient since it is on the read path for
stored configs and older clients, where unknown keys mean version skew
rather than user error.

_Opened by Coder Agents on behalf of @ethanndickson._

Relates to CODAGT-797
This commit is contained in:
Ethan
2026-07-14 13:32:03 +10:00
committed by GitHub
parent 0c3c65d85b
commit b0f0a54ab7
2 changed files with 49 additions and 1 deletions
+24 -1
View File
@@ -1484,6 +1484,29 @@ type ChatModelCallConfig struct {
// UnmarshalJSON accepts both the current nested cost object and the previous
// top-level pricing keys so legacy stored model_config JSON continues to load.
func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error {
return c.unmarshal(data, json.Unmarshal)
}
// UnmarshalStrict is UnmarshalJSON except unknown fields are an error instead
// of being silently dropped. Clients that accept free-form model config JSON
// (e.g. the Terraform provider) use it to reject settings this SDK version
// does not recognize before they are lost.
func (c *ChatModelCallConfig) UnmarshalStrict(data []byte) error {
return c.unmarshal(data, func(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
// Match json.Unmarshal: reject any trailing data after the value.
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return xerrors.New("unexpected trailing data after JSON value")
}
return nil
})
}
func (c *ChatModelCallConfig) unmarshal(data []byte, decode func(data []byte, v any) error) error {
type chatModelCallConfigAlias ChatModelCallConfig
aux := struct {
*chatModelCallConfigAlias
@@ -1494,7 +1517,7 @@ func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error {
}{
chatModelCallConfigAlias: (*chatModelCallConfigAlias)(c),
}
if err := json.Unmarshal(data, &aux); err != nil {
if err := decode(data, &aux); err != nil {
return err
}
+25
View File
@@ -548,6 +548,31 @@ func TestChatModelCallConfig_UnmarshalLegacyPricing(t *testing.T) {
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestChatModelCallConfig_UnmarshalStrict(t *testing.T) {
t.Parallel()
var decoded codersdk.ChatModelCallConfig
err := decoded.UnmarshalStrict([]byte(`{
"temperature": 0.5,
"cost": {"input_price_per_million_tokens": "5"},
"input_price_per_million_tokens": 1.5,
"provider_options": {"anthropic": {"thinking": {"budget_tokens": 1024}}}
}`))
require.NoError(t, err)
require.NotNil(t, decoded.Temperature)
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("5")))
err = decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
require.ErrorContains(t, err, `unknown field "bogus_setting"`)
// Trailing data after the first value is rejected, matching json.Unmarshal.
err = decoded.UnmarshalStrict([]byte(`{"temperature": 0.5} {"bogus_setting": true}`))
require.ErrorContains(t, err, "trailing data")
// UnmarshalJSON stays lenient.
require.NoError(t, json.Unmarshal([]byte(`{"bogus_setting": true}`), &decoded))
}
func TestChatCostSummary_JSONRoundTrip(t *testing.T) {
t.Parallel()