feat: per-user per-model chat compaction threshold overrides (#23412)

## What

Adds per-user per-model auto-compaction threshold overrides. Users can
now customize the percentage of context window usage that triggers chat
compaction, independently for each enabled model.

## Why

The compaction threshold was previously only configurable at the
deployment level (`chat_model_configs.compression_threshold`). Different
users have different preferences — some want aggressive compaction to
keep costs low, others prefer higher thresholds to retain more context.
This gives users control without requiring admin intervention.

## Architecture

**Storage:** Reuses the existing `user_configs` table (no migration
needed). Overrides are stored as key/value pairs with keys shaped
`chat_compaction_threshold:<modelConfigID>` and integer percent values.

**API:** Three new experimental endpoints under
`/api/experimental/chats/config/`:
- `GET /user-compaction-thresholds` — list all overrides for the current
user
- `PUT /user-compaction-thresholds/{modelConfig}` — upsert an override
(validates model exists and is enabled, validates 0–100 range)
- `DELETE /user-compaction-thresholds/{modelConfig}` — clear an override
(idempotent)

**Runtime resolution:** In `coderd/chatd/chatd.go`, a new
`resolveUserCompactionThreshold()` helper runs at the start of each chat
turn (inside `runChat()`), after the model config is resolved but before
`CompactionOptions` is built. If a valid override exists, it replaces
`modelConfig.CompressionThreshold`. The threshold source
(`user_override` vs `model_default`) is logged with each compaction
event.

**Precedence:** `effectiveThreshold = userOverride ??
modelConfig.CompressionThreshold`

**UI:** New "Context Compaction" subsection in the Agents → Settings →
Behavior tab, placed after Personal Instructions. Shows one row per
enabled model with the system default, a number input for the override,
and Save/Reset controls.

## Testing

- 9 API subtests covering CRUD, validation (boundary values 0/100,
out-of-range rejection), upsert behavior, idempotent delete, user
isolation, and non-existent model config
- 4 dbauthz tests (16 scenarios) verifying `ActionReadPersonal` /
`ActionUpdatePersonal` on all query methods
- 4 Storybook stories with play functions (Default, WithOverrides,
Loading, Error)

<details>
<summary>Implementation plan</summary>

### Phase 1 — Tests
- Backend API tests in `coderd/chats_test.go` (9 subtests)
- Database auth wrapper tests in
`coderd/database/dbauthz/dbauthz_test.go` (4 methods)
- Frontend stories in `UserCompactionThresholdSettings.stories.tsx` (4
stories)

### Phase 2 — Backend preference surface
- 4 SQL queries in `coderd/database/queries/users.sql` (list, get,
upsert, delete)
- `make gen` to propagate into generated artifacts
- Auth/metrics wrappers in dbauthz and dbmetrics
- SDK types and client methods in `codersdk/chats.go`
- HTTP handlers and routes in `coderd/chats.go` and `coderd/coderd.go`
- Key prefix constant shared between handlers and runtime

### Phase 3 — Runtime override
- `resolveUserCompactionThreshold()` helper in `coderd/chatd/chatd.go`
- Override injection in `runChat()` before building `CompactionOptions`
- `threshold_source` field added to compaction log

### Phase 4 — Settings UI
- API client methods and React Query hooks in `site/src/api/`
- `UserCompactionThresholdSettings` component extracted from
`SettingsPageContent`
- Per-model mutation tracking (only the active row disables during save)
- 100% warning, "System default" label, helpful empty state copy

### Phase 5 — Refactor and review fixes
- Consolidated key prefix constant in `codersdk`
- Explicit PUT range validation (not just struct tags)
- GET handler gracefully skips malformed rows instead of 500
- Boundary value, upsert, and non-existent model config tests
- UX improvements: per-model mutation state, aria-live on errors

</details>
This commit is contained in:
Michael Suchacz
2026-03-24 00:48:18 +01:00
committed by GitHub
parent acbfb90c30
commit 82f965a0ae
21 changed files with 1432 additions and 5 deletions
+73
View File
@@ -22,6 +22,16 @@ import (
"github.com/coder/websocket/wsjson"
)
// ChatCompactionThresholdKeyPrefix scopes per-model chat compaction
// threshold settings.
const ChatCompactionThresholdKeyPrefix = "chat_compaction_threshold_pct:"
// CompactionThresholdKey returns the user-config key for a specific
// model configuration's compaction threshold.
func CompactionThresholdKey(modelConfigID uuid.UUID) string {
return ChatCompactionThresholdKeyPrefix + modelConfigID.String()
}
// ChatStatus represents the status of a chat.
type ChatStatus string
@@ -349,6 +359,25 @@ type UserChatCustomPrompt struct {
CustomPrompt string `json:"custom_prompt"`
}
// UserChatCompactionThreshold is a user's per-model chat compaction
// threshold override.
type UserChatCompactionThreshold struct {
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
ThresholdPercent int32 `json:"threshold_percent"`
}
// UserChatCompactionThresholds wraps the user's per-model chat
// compaction threshold overrides.
type UserChatCompactionThresholds struct {
Thresholds []UserChatCompactionThreshold `json:"thresholds"`
}
// UpdateUserChatCompactionThresholdRequest sets a user's per-model
// chat compaction threshold override.
type UpdateUserChatCompactionThresholdRequest struct {
ThresholdPercent int32 `json:"threshold_percent" validate:"min=0,max=100"`
}
// ChatDesktopEnabledResponse is the response for getting the desktop setting.
type ChatDesktopEnabledResponse struct {
EnableDesktop bool `json:"enable_desktop"`
@@ -1413,6 +1442,50 @@ func (c *ExperimentalClient) UpdateUserChatCustomPrompt(ctx context.Context, req
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetUserChatCompactionThresholds fetches the user's per-model chat
// compaction thresholds.
func (c *ExperimentalClient) GetUserChatCompactionThresholds(ctx context.Context) (UserChatCompactionThresholds, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-compaction-thresholds", nil)
if err != nil {
return UserChatCompactionThresholds{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserChatCompactionThresholds{}, ReadBodyAsError(res)
}
var thresholds UserChatCompactionThresholds
return thresholds, json.NewDecoder(res.Body).Decode(&thresholds)
}
// UpdateUserChatCompactionThreshold updates the user's per-model chat
// compaction threshold.
func (c *ExperimentalClient) UpdateUserChatCompactionThreshold(ctx context.Context, modelConfigID uuid.UUID, req UpdateUserChatCompactionThresholdRequest) (UserChatCompactionThreshold, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/experimental/chats/config/user-compaction-thresholds/%s", modelConfigID), req)
if err != nil {
return UserChatCompactionThreshold{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserChatCompactionThreshold{}, ReadBodyAsError(res)
}
var threshold UserChatCompactionThreshold
return threshold, json.NewDecoder(res.Body).Decode(&threshold)
}
// DeleteUserChatCompactionThreshold deletes the user's per-model chat
// compaction threshold override.
func (c *ExperimentalClient) DeleteUserChatCompactionThreshold(ctx context.Context, modelConfigID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/chats/config/user-compaction-thresholds/%s", modelConfigID), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// CreateChat creates a new chat.
func (c *ExperimentalClient) CreateChat(ctx context.Context, req CreateChatRequest) (Chat, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats", req)