mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user