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:
+37
-1
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -3150,8 +3151,14 @@ func (p *Server) runChat(
|
||||
// "Summarizing..." tool call with the "Summarized" tool
|
||||
// result.
|
||||
compactionToolCallID := "chat_summarized_" + uuid.NewString()
|
||||
effectiveThreshold := modelConfig.CompressionThreshold
|
||||
thresholdSource := "model_default"
|
||||
if override, ok := p.resolveUserCompactionThreshold(ctx, chat.OwnerID, modelConfig.ID); ok {
|
||||
effectiveThreshold = override
|
||||
thresholdSource = "user_override"
|
||||
}
|
||||
compactionOptions := &chatloop.CompactionOptions{
|
||||
ThresholdPercent: modelConfig.CompressionThreshold,
|
||||
ThresholdPercent: effectiveThreshold,
|
||||
ContextLimit: modelConfig.ContextLimit,
|
||||
Persist: func(
|
||||
persistCtx context.Context,
|
||||
@@ -3168,6 +3175,7 @@ func (p *Server) runChat(
|
||||
}
|
||||
logger.Info(persistCtx, "chat context summarized",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("threshold_source", thresholdSource),
|
||||
slog.F("threshold_percent", result.ThresholdPercent),
|
||||
slog.F("usage_percent", result.UsagePercent),
|
||||
slog.F("context_tokens", result.ContextTokens),
|
||||
@@ -3718,6 +3726,34 @@ func (p *Server) resolveInstructions(
|
||||
return instruction
|
||||
}
|
||||
|
||||
// resolveUserCompactionThreshold looks up the user's per-model
|
||||
// compaction threshold override. Returns the override value and
|
||||
// true if one exists and is valid, or 0 and false otherwise.
|
||||
func (p *Server) resolveUserCompactionThreshold(ctx context.Context, userID uuid.UUID, modelConfigID uuid.UUID) (int32, bool) {
|
||||
raw, err := p.db.GetUserChatCompactionThreshold(ctx, database.GetUserChatCompactionThresholdParams{
|
||||
UserID: userID,
|
||||
Key: codersdk.CompactionThresholdKey(modelConfigID),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "failed to fetch compaction threshold override",
|
||||
slog.F("user_id", userID),
|
||||
slog.F("model_config_id", modelConfigID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return 0, false
|
||||
}
|
||||
// Range 0..100 must stay in sync with handler validation in
|
||||
// coderd/chats.go.
|
||||
val, err := strconv.ParseInt(raw, 10, 32)
|
||||
if err != nil || val < 0 || val > 100 {
|
||||
return 0, false
|
||||
}
|
||||
return int32(val), true
|
||||
}
|
||||
|
||||
// resolveUserPrompt fetches the user's custom chat prompt from the
|
||||
// database and wraps it in <user-instructions> tags. Returns empty
|
||||
// string if no prompt is set.
|
||||
|
||||
@@ -2,6 +2,7 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -606,6 +607,85 @@ func TestPublishToStream_DropWarnRateLimiting(t *testing.T) {
|
||||
requireFieldValue(t, subWarn[2], "dropped_count", int64(1))
|
||||
}
|
||||
|
||||
func TestResolveUserCompactionThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userID := uuid.New()
|
||||
modelConfigID := uuid.New()
|
||||
expectedKey := codersdk.CompactionThresholdKey(modelConfigID)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dbReturn string
|
||||
dbErr error
|
||||
wantVal int32
|
||||
wantOK bool
|
||||
wantWarnLog bool
|
||||
}{
|
||||
{
|
||||
name: "NoRowsReturnsDefault",
|
||||
dbErr: sql.ErrNoRows,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidOverride",
|
||||
dbReturn: "75",
|
||||
wantVal: 75,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "OutOfRangeValue",
|
||||
dbReturn: "101",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "NonIntegerValue",
|
||||
dbReturn: "abc",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "UnexpectedDBError",
|
||||
dbErr: xerrors.New("connection refused"),
|
||||
wantOK: false,
|
||||
wantWarnLog: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockDB := dbmock.NewMockStore(ctrl)
|
||||
sink := testutil.NewFakeSink(t)
|
||||
|
||||
srv := &Server{
|
||||
db: mockDB,
|
||||
logger: sink.Logger(),
|
||||
}
|
||||
|
||||
mockDB.EXPECT().GetUserChatCompactionThreshold(gomock.Any(), database.GetUserChatCompactionThresholdParams{
|
||||
UserID: userID,
|
||||
Key: expectedKey,
|
||||
}).Return(tc.dbReturn, tc.dbErr)
|
||||
|
||||
val, ok := srv.resolveUserCompactionThreshold(context.Background(), userID, modelConfigID)
|
||||
require.Equal(t, tc.wantVal, val)
|
||||
require.Equal(t, tc.wantOK, ok)
|
||||
|
||||
warns := sink.Entries(func(e slog.SinkEntry) bool {
|
||||
return e.Level == slog.LevelWarn
|
||||
})
|
||||
if tc.wantWarnLog {
|
||||
require.NotEmpty(t, warns, "expected a warning log entry")
|
||||
return
|
||||
}
|
||||
require.Empty(t, warns, "unexpected warning log entry")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requireFieldValue asserts that a SinkEntry contains a field with
|
||||
// the given name and value.
|
||||
func requireFieldValue(t *testing.T, entry slog.SinkEntry, name string, expected interface{}) {
|
||||
|
||||
Reference in New Issue
Block a user