feat(quota): channel quota limiter + per-run tool budget + config UI

Part A — Channel quota limiter (managed mode):
- DB-backed per-user/group request quotas with in-memory 60s TTL cache
- Config merge priority: Groups > Channels > Providers > Default
- Per-group quota override via channels.telegram.groups[chatID].quota
- Migration 000009: index on channel_requests for quota queries
- Hot-reload quota config via pub/sub (TopicConfigChanged)

Part B — Per-run tool call budget:
- Soft stop at configurable limit (default 25, per-agent override)
- MaxToolCalls field on AgentDefaults + AgentSpec + LoopConfig
- LLM gets one final call to summarize when budget exceeded

Part C — Web UI + config page refactor:
- QuotaSection with provider/channel dropdowns (useProviders, useChannelInstances)
- Config page refactored to vertical sidebar tabs layout
- Categories: General, Quota, Agents, Tools, Connections, Advanced, Raw Editor
- Fixed config.patch RPC to serialize raw JSON + baseHash correctly
- Config change pub/sub broadcast from handleApply/handlePatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-03-04 10:11:17 +07:00
1 parent d62fd830e0
commit ed32e68c68
22 files changed
+844 -96

No files matched your search

+1
View File
@@ -211,6 +211,7 @@ func bootstrapStandaloneAgent(cfg *config.Config, agentName string) (*agent.Loop
Model: agentCfg.Model,
ContextWindow: agentCfg.ContextWindow,
MaxIterations: agentCfg.MaxToolIterations,
MaxToolCalls: agentCfg.MaxToolCalls,
Workspace: workspace,
Bus: msgBus,
Sessions: sessStore,
+32 -1
View File
@@ -909,7 +909,38 @@ func runGateway() {
if managedStores != nil {
consumerTeamStore = managedStores.Teams
}
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore)
// Quota checker (managed mode only): enforces per-user/group request limits.
// Merge per-group quotas from channel configs into gateway.quota.groups.
config.MergeChannelGroupQuotas(cfg)
var quotaChecker *channels.QuotaChecker
if managedStores != nil && cfg.Gateway.Quota != nil && cfg.Gateway.Quota.Enabled {
quotaChecker = channels.NewQuotaChecker(managedStores.DB, *cfg.Gateway.Quota)
defer quotaChecker.Stop()
slog.Info("channel quota enabled",
"default_hour", cfg.Gateway.Quota.Default.Hour,
"default_day", cfg.Gateway.Quota.Default.Day,
"default_week", cfg.Gateway.Quota.Default.Week,
)
}
// Reload quota config on config changes via pub/sub.
if quotaChecker != nil {
msgBus.Subscribe("quota-config-reload", func(evt bus.Event) {
if evt.Name != bus.TopicConfigChanged {
return
}
updatedCfg, ok := evt.Payload.(*config.Config)
if !ok || updatedCfg.Gateway.Quota == nil {
return
}
config.MergeChannelGroupQuotas(updatedCfg)
quotaChecker.UpdateConfig(*updatedCfg.Gateway.Quota)
slog.Info("quota config reloaded via pub/sub")
})
}
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker)
go func() {
sig := <-sigCh
+1
View File
@@ -94,6 +94,7 @@ func createAgentLoop(agentID string, cfg *config.Config, router *agent.Router, p
Model: agentCfg.Model,
ContextWindow: agentCfg.ContextWindow,
MaxIterations: agentCfg.MaxToolIterations,
MaxToolCalls: agentCfg.MaxToolCalls,
Workspace: workspace,
Bus: msgBus,
Sessions: sess,
+25 -2
View File
@@ -41,7 +41,7 @@ func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.Ru
// and routes them through the scheduler/agent loop, then publishes the response back.
// Also handles subagent announcements: routes them through the parent agent's session
// (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user.
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore) {
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker) {
slog.Info("inbound message consumer started")
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
@@ -66,7 +66,8 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
}
}
if _, err := agents.Get(agentID); err != nil {
agentLoop, err := agents.Get(agentID)
if err != nil {
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
return
}
@@ -112,6 +113,28 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
}
// --- Quota check (managed mode only) ---
if quotaChecker != nil {
qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
if !qResult.Allowed {
slog.Warn("security.quota_exceeded",
"user_id", userID,
"channel", msg.Channel,
"window", qResult.Window,
"used", qResult.Used,
"limit", qResult.Limit,
)
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: formatQuotaExceeded(qResult),
Metadata: msg.Metadata,
})
return
}
quotaChecker.Increment(userID)
}
slog.Info("inbound: scheduling message (main lane)",
"channel", msg.Channel,
"chat_id", msg.ChatID,
+10
View File
@@ -1,8 +1,11 @@
package cmd
import (
"fmt"
"log/slog"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/channels"
)
// Matching TS pi-embedded-helpers/errors.ts error classification.
@@ -95,3 +98,10 @@ func containsAny(s string, substrs ...string) bool {
}
return false
}
// formatQuotaExceeded formats a user-friendly quota exceeded message.
func formatQuotaExceeded(result channels.QuotaResult) string {
labels := map[string]string{"hour": "Hourly", "day": "Daily", "week": "Weekly"}
return fmt.Sprintf("⚠️ %s request limit reached (%d/%d). Please try again later.",
labels[result.Window], result.Used, result.Limit)
}
+1 -1
View File
@@ -19,7 +19,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
methods.NewChatMethods(agents, sessStore, isManaged, server.RateLimiter()).Register(router)
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, isManaged, contextFileInterceptor).Register(router)
methods.NewSessionsMethods(sessStore).Register(router)
methods.NewConfigMethods(cfg, cfgPath, isManaged, configSecretsStore).Register(router)
methods.NewConfigMethods(cfg, cfgPath, isManaged, configSecretsStore, msgBus).Register(router)
// Phase 2: Skills (uses SkillStore interface — PG or File)
methods.NewSkillsMethods(skillStore).Register(router)
+25
View File
@@ -52,6 +52,7 @@ type Loop struct {
model string
contextWindow int
maxIterations int
maxToolCalls int
workspace string
eventPub bus.EventPublisher // currently unused by Loop; kept for future use
@@ -127,6 +128,7 @@ type LoopConfig struct {
Model string
ContextWindow int
MaxIterations int
MaxToolCalls int
Workspace string
Bus bus.EventPublisher
Sessions store.SessionStore
@@ -214,6 +216,7 @@ func NewLoop(cfg LoopConfig) *Loop {
model: cfg.Model,
contextWindow: cfg.ContextWindow,
maxIterations: cfg.MaxIterations,
maxToolCalls: cfg.MaxToolCalls,
workspace: cfg.Workspace,
eventPub: cfg.Bus,
sessions: cfg.Sessions,
@@ -593,6 +596,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
var loopDetector toolLoopState // detects repeated no-progress tool calls
var totalUsage providers.Usage
iteration := 0
totalToolCalls := 0
var finalContent string
var asyncToolCalls []string // track async spawn tool names for fallback
var mediaResults []MediaResult // media files from tool MEDIA: results
@@ -779,6 +783,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
}
}
// Tool budget check: soft stop when total tool calls exceed the per-agent limit.
// Same pattern as maxIterations — no error thrown, LLM summarizes and returns.
totalToolCalls += len(resp.ToolCalls)
if l.maxToolCalls > 0 && totalToolCalls > l.maxToolCalls {
slog.Warn("security.tool_budget_exceeded",
"agent", l.id, "total", totalToolCalls, "limit", l.maxToolCalls)
messages = append(messages, providers.Message{
Role: "user",
Content: fmt.Sprintf("[System] Tool call budget reached (%d/%d). Do NOT call any more tools. Summarize results so far and respond to the user.", totalToolCalls, l.maxToolCalls),
})
continue // one more LLM call for summarization, then loop exits (no tool calls)
}
// Execute tool calls (parallel when multiple, sequential when single)
if len(resp.ToolCalls) == 1 {
// Single tool: sequential — no goroutine overhead
@@ -1270,3 +1287,11 @@ func sanitizePathSegment(s string) string {
func (l *Loop) InvalidateUserWorkspace(userID string) {
l.userWorkspaces.Delete(userID)
}
// ProviderName returns the name of this agent's LLM provider (e.g. "anthropic", "openai").
func (l *Loop) ProviderName() string {
if l.provider == nil {
return ""
}
return l.provider.Name()
}
+1
View File
@@ -9,4 +9,5 @@ type Agent interface {
Run(ctx context.Context, req RunRequest) (*RunResult, error)
IsRunning() bool
Model() string
ProviderName() string
}
+1
View File
@@ -69,6 +69,7 @@ const (
TopicCacheGroupFileWriters = "cache:group_file_writers"
TopicCacheSkillGrants = "cache:skill_grants"
TopicChannelStreaming = "channel-streaming"
TopicConfigChanged = "config:changed"
)
// CacheInvalidatePayload signals cache layers to evict stale entries.
+198
View File
@@ -0,0 +1,198 @@
package channels
import (
"context"
"database/sql"
"log/slog"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// QuotaResult is returned by QuotaChecker.Check.
type QuotaResult struct {
Allowed bool // whether the request is within quota
Window string // which window was exceeded: "hour", "day", "week"
Used int // current usage in the exceeded window
Limit int // configured limit for the exceeded window
}
// quotaCounts holds cached request counts for a user.
type quotaCounts struct {
hour, day, week int
fetchedAt time.Time
}
// QuotaChecker enforces per-user/group request quotas by counting top-level
// traces in the database. Results are cached in-memory for cacheTTL.
// Managed mode only — nil-safe (standalone mode passes nil).
type QuotaChecker struct {
db *sql.DB
config config.QuotaConfig
cache map[string]*quotaCounts
cacheTTL time.Duration
mu sync.RWMutex
stopCh chan struct{}
}
// NewQuotaChecker creates a quota checker backed by the traces table.
// Starts a background goroutine to evict stale cache entries.
func NewQuotaChecker(db *sql.DB, cfg config.QuotaConfig) *QuotaChecker {
qc := &QuotaChecker{
db: db,
config: cfg,
cache: make(map[string]*quotaCounts),
cacheTTL: 60 * time.Second,
stopCh: make(chan struct{}),
}
go qc.cleanupLoop()
return qc
}
// Stop shuts down the background cleanup goroutine.
func (qc *QuotaChecker) Stop() {
close(qc.stopCh)
}
// UpdateConfig replaces the quota configuration (e.g., after config reload).
func (qc *QuotaChecker) UpdateConfig(cfg config.QuotaConfig) {
qc.mu.Lock()
defer qc.mu.Unlock()
qc.config = cfg
}
// Check verifies if a user is within their quota limits.
// Returns QuotaResult with Allowed=true if all windows are within limits.
func (qc *QuotaChecker) Check(ctx context.Context, userID, channel, provider string) QuotaResult {
window := qc.resolveWindow(userID, channel, provider)
if window.IsZero() {
return QuotaResult{Allowed: true}
}
counts := qc.getCounts(ctx, userID)
// Check each window — report the first exceeded
if window.Hour > 0 && counts.hour >= window.Hour {
return QuotaResult{Allowed: false, Window: "hour", Used: counts.hour, Limit: window.Hour}
}
if window.Day > 0 && counts.day >= window.Day {
return QuotaResult{Allowed: false, Window: "day", Used: counts.day, Limit: window.Day}
}
if window.Week > 0 && counts.week >= window.Week {
return QuotaResult{Allowed: false, Window: "week", Used: counts.week, Limit: window.Week}
}
return QuotaResult{Allowed: true}
}
// Increment optimistically bumps cached counts after a request is accepted.
// This prevents multiple rapid requests from bypassing the quota within the
// cache TTL window.
func (qc *QuotaChecker) Increment(userID string) {
qc.mu.Lock()
defer qc.mu.Unlock()
if c, ok := qc.cache[userID]; ok {
c.hour++
c.day++
c.week++
}
}
// resolveWindow returns the effective quota window for a user, applying
// config merge priority: Groups > Channels > Providers > Default.
func (qc *QuotaChecker) resolveWindow(userID, channel, provider string) config.QuotaWindow {
qc.mu.RLock()
cfg := qc.config
qc.mu.RUnlock()
// Most specific: per-user/group override
if w, ok := cfg.Groups[userID]; ok && !w.IsZero() {
return w
}
// Channel-level override
if channel != "" {
if w, ok := cfg.Channels[channel]; ok && !w.IsZero() {
return w
}
}
// Provider-level override
if provider != "" {
if w, ok := cfg.Providers[provider]; ok && !w.IsZero() {
return w
}
}
return cfg.Default
}
// getCounts returns cached or fresh counts for a user.
func (qc *QuotaChecker) getCounts(ctx context.Context, userID string) quotaCounts {
qc.mu.RLock()
if c, ok := qc.cache[userID]; ok && time.Since(c.fetchedAt) < qc.cacheTTL {
counts := *c
qc.mu.RUnlock()
return counts
}
qc.mu.RUnlock()
// Cache miss — query DB
counts := qc.queryDB(ctx, userID)
qc.mu.Lock()
qc.cache[userID] = &counts
qc.mu.Unlock()
return counts
}
// queryDB counts top-level traces for a user across time windows in a single query.
// Uses idx_traces_quota partial index: (user_id, created_at DESC) WHERE parent_trace_id IS NULL.
func (qc *QuotaChecker) queryDB(ctx context.Context, userID string) quotaCounts {
now := time.Now().UTC()
hourAgo := now.Add(-1 * time.Hour)
dayAgo := now.Add(-24 * time.Hour)
weekAgo := now.Add(-7 * 24 * time.Hour)
var counts quotaCounts
counts.fetchedAt = now
err := qc.db.QueryRowContext(ctx, `
SELECT
COUNT(*) FILTER (WHERE created_at >= $2) AS hour_count,
COUNT(*) FILTER (WHERE created_at >= $3) AS day_count,
COUNT(*) FILTER (WHERE created_at >= $4) AS week_count
FROM traces
WHERE user_id = $1 AND parent_trace_id IS NULL AND created_at >= $4`,
userID, hourAgo, dayAgo, weekAgo,
).Scan(&counts.hour, &counts.day, &counts.week)
if err != nil {
slog.Warn("quota: failed to query trace counts", "user_id", userID, "error", err)
}
return counts
}
// cleanupLoop periodically evicts stale cache entries.
func (qc *QuotaChecker) cleanupLoop() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-qc.stopCh:
return
case <-ticker.C:
qc.mu.Lock()
staleThreshold := time.Now().Add(-5 * time.Minute)
for k, v := range qc.cache {
if v.fetchedAt.Before(staleThreshold) {
delete(qc.cache, k)
}
}
qc.mu.Unlock()
}
}
}
+2
View File
@@ -119,6 +119,7 @@ type AgentDefaults struct {
Temperature float64 `json:"temperature"`
MaxToolIterations int `json:"max_tool_iterations"`
ContextWindow int `json:"context_window"`
MaxToolCalls int `json:"max_tool_calls,omitempty"` // max total tool calls per run (0 = unlimited, default 25)
AgentType string `json:"agent_type,omitempty"` // "open" (default) or "predefined"
Subagents *SubagentsConfig `json:"subagents,omitempty"`
Sandbox *SandboxConfig `json:"sandbox,omitempty"`
@@ -376,6 +377,7 @@ type AgentSpec struct {
Temperature float64 `json:"temperature,omitempty"`
MaxToolIterations int `json:"max_tool_iterations,omitempty"`
ContextWindow int `json:"context_window,omitempty"`
MaxToolCalls int `json:"max_tool_calls,omitempty"` // per-agent override
AgentType string `json:"agent_type,omitempty"` // "open" or "predefined"
Skills []string `json:"skills,omitempty"` // nil = all skills allowed
Tools *ToolPolicySpec `json:"tools,omitempty"` // per-agent tool policy
+43
View File
@@ -53,6 +53,7 @@ type TelegramGroupConfig struct {
Tools []string `json:"tools,omitempty"` // tool allow list (nil = all, supports "group:xxx")
SystemPrompt string `json:"system_prompt,omitempty"` // extra system prompt for this group
Topics map[string]*TelegramTopicConfig `json:"topics,omitempty"` // per-topic overrides (key: thread ID string)
Quota *QuotaWindow `json:"quota,omitempty"` // per-group quota override
}
// TelegramTopicConfig defines per-topic overrides within a Telegram group.
@@ -178,6 +179,26 @@ func (c *Config) HasAnyProvider() bool {
p.Bailian.APIKey != ""
}
// QuotaWindow defines request limits per time window. Zero means unlimited.
type QuotaWindow struct {
Hour int `json:"hour,omitempty"` // max requests per hour (0 = unlimited)
Day int `json:"day,omitempty"` // max requests per day (0 = unlimited)
Week int `json:"week,omitempty"` // max requests per week (0 = unlimited)
}
// IsZero returns true if no limits are set.
func (w QuotaWindow) IsZero() bool { return w.Hour == 0 && w.Day == 0 && w.Week == 0 }
// QuotaConfig configures per-user/group request quotas (managed mode only).
// Config merge priority: Groups > Channels > Providers > Default.
type QuotaConfig struct {
Enabled bool `json:"enabled"`
Default QuotaWindow `json:"default"`
Providers map[string]QuotaWindow `json:"providers,omitempty"` // key = provider name (e.g. "anthropic")
Channels map[string]QuotaWindow `json:"channels,omitempty"` // key = channel name (e.g. "telegram")
Groups map[string]QuotaWindow `json:"groups,omitempty"` // key = userID (e.g. "group:telegram:-100123")
}
// GatewayConfig controls the gateway server.
type GatewayConfig struct {
Host string `json:"host"`
@@ -189,6 +210,7 @@ type GatewayConfig struct {
RateLimitRPM int `json:"rate_limit_rpm,omitempty"` // rate limit: requests per minute per user (default 20, 0 = disabled)
InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off"
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid messages from same sender (default 1000ms, -1 = disabled)
Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas (managed mode only)
}
// ToolsConfig controls tool availability, policy, and web search.
@@ -332,3 +354,24 @@ type TtsMiniMaxConfig struct {
Model string `json:"model,omitempty"` // default "speech-02-hd"
VoiceID string `json:"voice_id,omitempty"` // default "Wise_Woman"
}
// MergeChannelGroupQuotas merges per-group quota overrides from channel configs
// (e.g., channels.telegram.groups[chatID].quota) into gateway.quota.groups.
// This allows per-group quotas to be set at the channel level and picked up
// by the QuotaChecker at runtime.
func MergeChannelGroupQuotas(cfg *Config) {
if cfg.Gateway.Quota == nil {
return
}
if cfg.Gateway.Quota.Groups == nil {
cfg.Gateway.Quota.Groups = make(map[string]QuotaWindow)
}
// Telegram per-group quotas
for chatID, groupCfg := range cfg.Channels.Telegram.Groups {
if groupCfg != nil && groupCfg.Quota != nil && !groupCfg.Quota.IsZero() {
key := "group:telegram:" + chatID
cfg.Gateway.Quota.Groups[key] = *groupCfg.Quota
}
}
}
+5
View File
@@ -24,6 +24,7 @@ func Default() *Config {
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 20,
MaxToolCalls: 25,
ContextWindow: 200000,
Subagents: &SubagentsConfig{
MaxConcurrent: 20,
@@ -54,6 +55,7 @@ func Default() *Config {
Security: "full",
Ask: "off",
},
RateLimitPerHour: 150,
},
Sessions: SessionsConfig{
Storage: "~/.goclaw/sessions",
@@ -310,6 +312,9 @@ func (c *Config) ResolveAgent(agentID string) AgentDefaults {
if spec.ContextWindow > 0 {
d.ContextWindow = spec.ContextWindow
}
if spec.MaxToolCalls > 0 {
d.MaxToolCalls = spec.MaxToolCalls
}
if spec.Workspace != "" {
d.Workspace = spec.Workspace
}
+13 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/titanous/json5"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -20,10 +21,11 @@ type ConfigMethods struct {
cfgPath string
managedMode bool
secretsStore store.ConfigSecretsStore // nil in standalone mode
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
}
func NewConfigMethods(cfg *config.Config, cfgPath string, managedMode bool, secretsStore store.ConfigSecretsStore) *ConfigMethods {
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, managedMode: managedMode, secretsStore: secretsStore}
func NewConfigMethods(cfg *config.Config, cfgPath string, managedMode bool, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, managedMode: managedMode, secretsStore: secretsStore, eventBus: eventBus}
}
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
@@ -94,6 +96,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
}
}
m.cfg.ApplyEnvOverrides()
m.broadcastChanged()
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"ok": true,
@@ -168,6 +171,7 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
}
}
m.cfg.ApplyEnvOverrides()
m.broadcastChanged()
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"ok": true,
@@ -178,6 +182,13 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
}))
}
// broadcastChanged notifies subscribers that config has been updated.
func (m *ConfigMethods) broadcastChanged() {
if m.eventBus != nil {
m.eventBus.Broadcast(bus.Event{Name: bus.TopicConfigChanged, Payload: m.cfg})
}
}
// handleSchema returns the config JSON schema for UI form generation.
// Matching TS config.schema (src/gateway/server-methods/config.ts:276-289).
func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+1
View File
@@ -23,6 +23,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
skillsDir = config.ExpandHome(skillsDir)
return &store.Stores{
DB: db,
Sessions: NewPGSessionStore(db),
Memory: NewPGMemoryStore(db, memCfg),
Cron: NewPGCronStore(db),
+3
View File
@@ -1,8 +1,11 @@
package store
import "database/sql"
// Stores is the top-level container for all storage backends.
// In standalone mode, managed-only stores (Agents, Providers, Tracing, MCP) are nil.
type Stores struct {
DB *sql.DB // underlying connection (nil in standalone mode)
Sessions SessionStore
Memory MemoryStore
Cron CronStore
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 8
const RequiredSchemaVersion uint = 9
@@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS idx_traces_quota;
+5
View File
@@ -0,0 +1,5 @@
-- Partial index for quota checker: efficiently counts top-level traces per user in time windows.
-- Eliminates parent_trace_id IS NULL post-filter (89% of traces are top-level).
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_traces_quota
ON traces (user_id, created_at DESC)
WHERE parent_trace_id IS NULL AND user_id IS NOT NULL;
+100 -88
View File
@@ -23,6 +23,7 @@ import { TtsSection } from "./sections/tts-section";
import { CronSection } from "./sections/cron-section";
import { TelemetrySection } from "./sections/telemetry-section";
import { BindingsSection } from "./sections/bindings-section";
import { QuotaSection } from "./sections/quota-section";
export function ConfigPage() {
const { config, hash, configPath, loading, saving, error, refresh, applyRaw, patch } = useConfig();
@@ -41,7 +42,7 @@ export function ConfigPage() {
}
}, [config]);
const handleSave = async () => {
const handleRawSave = async () => {
setSaveError(null);
try {
await applyRaw(rawText);
@@ -82,6 +83,8 @@ export function ConfigPage() {
);
}
const isManaged = (config.database as any)?.mode === "managed";
return (
<div className="p-6">
<PageHeader
@@ -114,17 +117,106 @@ export function ConfigPage() {
</span>
</div>
<Tabs defaultValue="ui" className="mt-4">
<TabsList>
<TabsTrigger value="ui">UI</TabsTrigger>
<Tabs orientation="vertical" defaultValue="general" className="mt-4 items-start">
<TabsList variant="line" className="w-44 shrink-0 sticky top-6 rounded-lg border bg-card p-3 shadow-sm">
<TabsTrigger value="general">General</TabsTrigger>
{isManaged && <TabsTrigger value="quota">Quota</TabsTrigger>}
<TabsTrigger value="agents">Agents</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
<div className="my-2 h-px w-full bg-border" />
<TabsTrigger value="raw">Raw Editor</TabsTrigger>
</TabsList>
<TabsContent value="ui" className="mt-4">
<ConfigUI config={config} onPatch={patch} saving={saving} />
<TabsContent value="general" className="space-y-4">
<GatewaySection
data={config.gateway as any}
onSave={(v) => patch({ gateway: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="raw" className="mt-4">
{isManaged && (
<TabsContent value="quota" className="space-y-4">
<QuotaSection
data={config.gateway as any}
onSave={(v) => patch({ gateway: v })}
saving={saving}
/>
</TabsContent>
)}
<TabsContent value="agents" className="space-y-4">
{isManaged ? (
<ManagedRedirect
title="LLM Providers"
description="Managed via the Providers page in managed mode."
to={ROUTES.PROVIDERS}
/>
) : (
<ProvidersSection
data={config.providers as any}
onSave={(v) => patch({ providers: v })}
saving={saving}
/>
)}
<AgentsDefaultsSection
data={config.agents as any}
onSave={(v) => patch({ agents: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="tools" className="space-y-4">
<ToolsSection
data={config.tools as any}
onSave={(v) => patch({ tools: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="connections" className="space-y-4">
<SessionsSection
data={config.sessions as any}
onSave={(v) => patch({ sessions: v })}
saving={saving}
/>
{isManaged ? (
<ManagedRedirect
title="Channels"
description="Managed via the Channels page in managed mode."
to={ROUTES.CHANNELS}
/>
) : (
<ChannelsSection
data={config.channels as any}
onSave={(v) => patch({ channels: v })}
saving={saving}
/>
)}
</TabsContent>
<TabsContent value="advanced" className="space-y-4">
<TtsSection data={config.tts as any} />
<CronSection
data={config.cron as any}
onSave={(v) => patch({ cron: v })}
saving={saving}
/>
<TelemetrySection
data={config.telemetry as any}
onSave={(v) => patch({ telemetry: v })}
saving={saving}
/>
<BindingsSection
data={config.bindings as any}
onSave={(v) => patch({ bindings: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="raw">
<div className="space-y-3">
<Textarea
value={rawText}
@@ -145,7 +237,7 @@ export function ConfigPage() {
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
onClick={handleRawSave}
disabled={!dirty || saving}
className="gap-1"
>
@@ -183,83 +275,3 @@ function ManagedRedirect({ title, description, to }: { title: string; descriptio
</Card>
);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
function ConfigUI({
config,
onPatch,
saving,
}: {
config: Record<string, unknown>;
onPatch: (updates: Record<string, unknown>) => Promise<void>;
saving: boolean;
}) {
const isManaged = (config.database as any)?.mode === "managed";
return (
<div className="space-y-4">
<GatewaySection
data={config.gateway as any}
onSave={(v) => onPatch({ gateway: v })}
saving={saving}
/>
{isManaged ? (
<ManagedRedirect
title="LLM Providers"
description="Managed via the Providers page in managed mode."
to={ROUTES.PROVIDERS}
/>
) : (
<ProvidersSection
data={config.providers as any}
onSave={(v) => onPatch({ providers: v })}
saving={saving}
/>
)}
<AgentsDefaultsSection
data={config.agents as any}
onSave={(v) => onPatch({ agents: v })}
saving={saving}
/>
<ToolsSection
data={config.tools as any}
onSave={(v) => onPatch({ tools: v })}
saving={saving}
/>
{isManaged ? (
<ManagedRedirect
title="Channels"
description="Managed via the Channels page in managed mode."
to={ROUTES.CHANNELS}
/>
) : (
<ChannelsSection
data={config.channels as any}
onSave={(v) => onPatch({ channels: v })}
saving={saving}
/>
)}
<SessionsSection
data={config.sessions as any}
onSave={(v) => onPatch({ sessions: v })}
saving={saving}
/>
<TtsSection data={config.tts as any} />
<CronSection
data={config.cron as any}
onSave={(v) => onPatch({ cron: v })}
saving={saving}
/>
<TelemetrySection
data={config.telemetry as any}
onSave={(v) => onPatch({ telemetry: v })}
saving={saving}
/>
<BindingsSection
data={config.bindings as any}
onSave={(v) => onPatch({ bindings: v })}
saving={saving}
/>
</div>
);
}
+5 -1
View File
@@ -62,7 +62,11 @@ export function useConfig() {
setSaving(true);
setError(null);
try {
await ws.call(Methods.CONFIG_PATCH, updates);
const res = await ws.call<{ hash: string }>(Methods.CONFIG_PATCH, {
raw: JSON.stringify(updates),
baseHash: hashRef.current,
});
hashRef.current = res.hash;
await invalidate();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to patch config");
@@ -0,0 +1,370 @@
import { useState, useEffect } from "react";
import { Save, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { InfoLabel } from "@/components/shared/info-label";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
interface QuotaWindow {
hour?: number;
day?: number;
week?: number;
}
interface QuotaData {
enabled: boolean;
default: QuotaWindow;
providers?: Record<string, QuotaWindow>;
channels?: Record<string, QuotaWindow>;
groups?: Record<string, QuotaWindow>;
}
const DEFAULT_QUOTA: QuotaData = {
enabled: false,
default: { hour: 40, day: 200, week: 1000 },
};
interface Props {
data: { quota?: QuotaData } | undefined;
onSave: (value: { quota: QuotaData }) => Promise<void>;
saving: boolean;
}
function QuotaWindowInputs({
value,
onChange,
}: {
value: QuotaWindow;
onChange: (v: QuotaWindow) => void;
}) {
return (
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-1.5">
<InfoLabel tip="Max requests per hour (0 = unlimited)">Hour</InfoLabel>
<Input
type="number"
min={0}
value={value.hour ?? 0}
onChange={(e) => onChange({ ...value, hour: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip="Max requests per day (0 = unlimited)">Day</InfoLabel>
<Input
type="number"
min={0}
value={value.day ?? 0}
onChange={(e) => onChange({ ...value, day: Number(e.target.value) })}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip="Max requests per week (0 = unlimited)">Week</InfoLabel>
<Input
type="number"
min={0}
value={value.week ?? 0}
onChange={(e) => onChange({ ...value, week: Number(e.target.value) })}
/>
</div>
</div>
);
}
function OverridesTable({
label,
tip,
entries,
onChange,
keyPlaceholder,
options,
}: {
label: string;
tip: string;
entries: Record<string, QuotaWindow>;
onChange: (v: Record<string, QuotaWindow>) => void;
keyPlaceholder: string;
options?: { value: string; label: string }[];
}) {
const keys = Object.keys(entries);
const usedKeys = new Set(keys);
const availableOptions = options?.filter((o) => !usedKeys.has(o.value));
const addRow = (key?: string) => {
const newKey = key ?? "";
if (newKey in entries) return;
onChange({ ...entries, [newKey]: { hour: 0, day: 0, week: 0 } });
};
const removeRow = (key: string) => {
const next = { ...entries };
delete next[key];
onChange(next);
};
const updateKey = (oldKey: string, newKey: string) => {
if (newKey !== oldKey && newKey in entries) return;
const next: Record<string, QuotaWindow> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const updateWindow = (key: string, window: QuotaWindow) => {
onChange({ ...entries, [key]: window });
};
return (
<div className="space-y-2">
<InfoLabel tip={tip}>{label}</InfoLabel>
{keys.map((key, i) => (
<div key={i} className="flex items-end gap-2">
<div className="grid gap-1.5 min-w-[180px]">
{i === 0 && (
<span className="text-xs text-muted-foreground">Key</span>
)}
{options ? (
<Select
value={key}
onValueChange={(v) => updateKey(key, v)}
>
<SelectTrigger>
<SelectValue placeholder={keyPlaceholder} />
</SelectTrigger>
<SelectContent>
{/* Current value always shown */}
{key && (
<SelectItem value={key}>
{options.find((o) => o.value === key)?.label ?? key}
</SelectItem>
)}
{availableOptions
?.filter((o) => o.value !== key)
.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder={keyPlaceholder}
value={key}
onChange={(e) => updateKey(key, e.target.value)}
/>
)}
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">Hour</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.hour ?? 0}
onChange={(e) =>
updateWindow(key, {
...entries[key],
hour: Number(e.target.value),
})
}
/>
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">Day</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.day ?? 0}
onChange={(e) =>
updateWindow(key, {
...entries[key],
day: Number(e.target.value),
})
}
/>
</div>
<div className="grid gap-1.5 w-20">
{i === 0 && (
<span className="text-xs text-muted-foreground">Week</span>
)}
<Input
type="number"
min={0}
value={entries[key]?.week ?? 0}
onChange={(e) =>
updateWindow(key, {
...entries[key],
week: Number(e.target.value),
})
}
/>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => removeRow(key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{options ? (
<Select
value=""
onValueChange={(v) => addRow(v)}
>
<SelectTrigger className="w-auto gap-1.5" size="sm">
<Plus className="h-3.5 w-3.5" />
<SelectValue placeholder="Add override" />
</SelectTrigger>
<SelectContent>
{availableOptions && availableOptions.length > 0 ? (
availableOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))
) : (
<SelectItem value="__none__" disabled>
All options added
</SelectItem>
)}
</SelectContent>
</Select>
) : (
<Button variant="outline" size="sm" onClick={() => addRow()} className="gap-1.5">
<Plus className="h-3.5 w-3.5" /> Add override
</Button>
)}
</div>
);
}
export function QuotaSection({ data, onSave, saving }: Props) {
const [draft, setDraft] = useState<QuotaData>(
data?.quota ?? DEFAULT_QUOTA
);
const [dirty, setDirty] = useState(false);
const { providers } = useProviders();
const { instances } = useChannelInstances();
const providerOptions = providers.map((p) => ({
value: p.name,
label: p.display_name || p.name,
}));
// Deduplicate channel types
const channelOptions = [
...new Map(
instances.map((c) => [c.channel_type, c.channel_type])
).entries(),
].map(([value]) => ({ value, label: value }));
useEffect(() => {
setDraft(data?.quota ?? DEFAULT_QUOTA);
setDirty(false);
}, [data]);
const update = (patch: Partial<QuotaData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Quota Limits</CardTitle>
<CardDescription>
Per-user/group request quotas. Limits are enforced per time window
(hour/day/week). Config merge priority: Group &gt; Channel &gt;
Provider &gt; Default.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<InfoLabel tip="Enable request quota enforcement">Enabled</InfoLabel>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => update({ enabled: v })}
/>
</div>
{draft.enabled && (
<>
<div className="space-y-2">
<InfoLabel tip="Default limits applied to all users/groups unless overridden">
Default Limits
</InfoLabel>
<QuotaWindowInputs
value={draft.default}
onChange={(v) => update({ default: v })}
/>
</div>
<OverridesTable
label="Provider Overrides"
tip="Per-provider quota limits (e.g., anthropic, openai)"
entries={draft.providers ?? {}}
onChange={(v) => update({ providers: v })}
keyPlaceholder="Select provider"
options={providerOptions}
/>
<OverridesTable
label="Channel Overrides"
tip="Per-channel quota limits (e.g., telegram, discord)"
entries={draft.channels ?? {}}
onChange={(v) => update({ channels: v })}
keyPlaceholder="Select channel"
options={channelOptions}
/>
<OverridesTable
label="Group/User Overrides"
tip="Per-user or group quota limits (e.g., group:telegram:-100123)"
entries={draft.groups ?? {}}
onChange={(v) => update({ groups: v })}
keyPlaceholder="group:telegram:-100123"
/>
</>
)}
{dirty && (
<div className="flex justify-end pt-2">
<Button
size="sm"
onClick={() => onSave({ quota: draft })}
disabled={saving}
className="gap-1.5"
>
<Save className="h-3.5 w-3.5" />{" "}
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
</CardContent>
</Card>
);
}