feat(agents): add model fallback configuration

This commit is contained in:
Duy Nguyen committed 2026-05-17 15:51:56 +07:00
1 parent 5a189330f9
commit 506e96cd63
30 files changed
+974 -32

No files matched your search

+10
View File
@@ -44,6 +44,16 @@ All HTTP-based providers (Anthropic, OpenAI-compatible, Codex) use 300-second ti
---
## Agent Model Fallback
Agents can define `model_fallback` as an ordered list of backup provider/model pairs. The agent's configured `provider` and `model` are always the primary route; fallback candidates are tried in UI order when the primary route returns a classifiable provider failure such as rate limit, overload, timeout, auth/billing failure, model-not-found, or unknown transport failure. Context overflow is not treated as fallback because it needs compaction, not a different model.
Fallback is runtime-only and per agent. Explicit `ProviderOverride` or `ModelOverride` requests bypass the fallback wrapper so manual runs, heartbeats, or call sites that intentionally choose a model keep exact override behavior.
Streaming fallback is conservative: backup models are tried only if the stream fails before any content, thinking, or image chunk is emitted.
---
## 2. Supported Providers
### Six Core Provider Types
+17
View File
@@ -68,6 +68,23 @@ The `Stores` struct is the top-level container holding all PostgreSQL-backed sto
---
## Agent Model Fallback Storage
Agent rows include `model_fallback`, stored as JSONB in PostgreSQL and TEXT JSON in SQLite. The config is per-agent and normalized before runtime use:
- `enabled`: whether fallback is active.
- `strategy`: currently `priority_order`.
- `candidates`: ordered backup provider/model pairs. The primary agent provider/model is not stored in this list.
- `max_attempts`: optional cap across primary plus fallback candidates.
- `cooldown_enabled`: temporarily skips recently failing routes when enabled.
Migration versions:
- PostgreSQL: `000065_agent_model_fallback`.
- SQLite: schema v33 to v34.
---
## 3. Session Caching
The session store uses an in-memory write-behind cache to minimize database I/O during the agent tool loop. All reads and writes happen in memory; data is flushed to the persistent backend only when `Save()` is called at the end of a run.
+49 -4
View File
@@ -25,8 +25,52 @@ Significant changes, features, and fixes in reverse chronological order.
- Added focused store and context interceptor coverage for permission preview and protected group context writes.
---
<<<<<<< HEAD
### CLI Credentials: per-agent env vars under Packages
**Features**
- Kept `CLI Credentials` as the Packages tab at `/packages?tab=cli-credentials` and preserved the legacy `/cli-credentials` redirect.
- Removed the duplicate standalone `CLI Credentials` item from the left sidebar.
- Added focused coverage for grant env payload semantics and routing contracts.
**Security**
- Nested agent-grant get/update/delete/reveal routes now verify the grant belongs to the binary ID in the URL.
- Grant creation now validates both the CLI binary and target agent exist in the authenticated tenant before inserting.
- Grant updates now validate env payloads before scalar writes, preventing partial state changes on 400 responses.
- Runtime env precedence is covered: per-user env overrides per-agent grant env for duplicate keys.
- Credentialed exec now fails closed if per-user env JSON is invalid.
- SQLite add-column migrations for replayed schema snapshots now skip already-present columns.
**Tests**
- Focused backend, store compile, UI unit, and web build validation pass.
- Live PostgreSQL validation skipped because `TEST_DATABASE_URL` is not set.
---
## 2026-05-16
### Agents: per-agent model fallback
**Features**
- Added per-agent `model_fallback` config with ordered provider/model candidates.
- Agent advanced config UI now supports enabling fallback, adding backup provider/model pairs, and drag-and-drop ordering.
- Runtime wraps the resolved agent provider with fallback only for normal agent execution. Explicit provider/model overrides bypass the fallback chain.
**Migrations**
- **PG:** `000065_agent_model_fallback` adds `agents.model_fallback JSONB NOT NULL DEFAULT '{}'`.
- **SQLite:** schema v33 to v34 adds `agents.model_fallback TEXT NOT NULL DEFAULT '{}'`.
**Tests**
- Focused provider, provider resolver, store tests pass. Main app builds in default and `sqliteonly` modes. Web production build passes.
---
## v3.11.3 — 2026-04-26
### Fixes
@@ -176,7 +220,9 @@ Implementation is evidence-backed against the native ChatGPT Responses API event
**Docs**
- Updated `docs/02-providers.md` and `docs/18-http-api.md` to describe the two-strategy model and the compatibility migration.
=======
---
## 2026-04-21
### Webhook fixes (post-review security & idempotency hardening)
@@ -209,7 +255,6 @@ Implementation is evidence-backed against the native ChatGPT Responses API event
- `GOCLAW_ENCRYPTION_KEY` is now **required** for webhook HMAC auth. Same key also encrypts LLM provider credentials.
---
>>>>>>> a83f4090 (fix(webhooks): address post-review findings (K1-K10))
## 2026-04-19
+4
View File
@@ -27,6 +27,10 @@ func (l *Loop) runViaPipeline(ctx context.Context, req RunRequest) (*RunResult,
provider := l.provider
if req.ProviderOverride != nil {
provider = req.ProviderOverride
} else if req.ModelOverride != "" {
if fallback, ok := provider.(interface{ PrimaryProvider() providers.Provider }); ok {
provider = fallback.PrimaryProvider()
}
}
p := pipeline.NewDefaultPipeline(deps)
+3 -3
View File
@@ -15,9 +15,9 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
"github.com/nextlevelbuilder/goclaw/internal/memory"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
@@ -158,7 +158,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
// Resolve provider (tenant-aware: tries tenant-specific first, falls back to master)
provider, err := providerresolve.ResolveConfiguredProvider(deps.ProviderReg, ag)
provider, err := providerresolve.ResolveAgentProvider(deps.ProviderReg, ag)
if err != nil {
// Fallback to any available provider for this tenant
names := deps.ProviderReg.ListForTenant(ag.TenantID)
@@ -467,7 +467,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
AgentOtherConfig: ag.OtherConfig,
AgentType: ag.AgentType,
IsTeamLead: isTeamLead,
AutoInjector: deps.AutoInjector,
AutoInjector: deps.AutoInjector,
Provider: provider,
Model: ag.Model,
ModelRegistry: deps.ModelRegistry,
+38
View File
@@ -287,6 +287,10 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
return
}
if err := validateAgentModelFallback(req.ModelFallback); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
return
}
if err := h.agents.Create(r.Context(), &req); err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
@@ -451,6 +455,20 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
return
}
validationAgent.ChatGPTOAuthRouting = rawRouting
allowed["chatgpt_oauth_routing"] = rawRouting
}
if fallback, ok := allowed["model_fallback"]; ok {
rawFallback, err := marshalJSONRaw(fallback)
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
if err := validateAgentModelFallback(rawFallback); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
return
}
validationAgent.ModelFallback = rawFallback
allowed["model_fallback"] = rawFallback
}
if err := validateChatGPTOAuthAgentRouting(
@@ -497,6 +515,26 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
func validateAgentModelFallback(raw json.RawMessage) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var cfg store.ModelFallbackConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("invalid model_fallback")
}
normalized := store.NormalizeModelFallbackConfig(&cfg)
if normalized == nil {
return nil
}
for _, candidate := range normalized.Candidates {
if candidate.Provider == "" || candidate.Model == "" {
return fmt.Errorf("fallback candidates require provider and model")
}
}
return nil
}
// syncIdentityName updates the Name: field in the agent's IDENTITY.md (agent-level and
// all per-user copies for open agents) so the agent self-reports the new display name.
// Errors are logged but do not fail the rename request.
+2
View File
@@ -124,6 +124,7 @@ func marshalAgentConfig(ag *store.AgentData) ([]byte, error) {
ReasoningConfig json.RawMessage `json:"reasoning_config,omitempty"`
WorkspaceSharing json.RawMessage `json:"workspace_sharing,omitempty"`
ChatGPTOAuthRouting json.RawMessage `json:"chatgpt_oauth_routing,omitempty"`
ModelFallback json.RawMessage `json:"model_fallback,omitempty"`
ShellDenyGroups json.RawMessage `json:"shell_deny_groups,omitempty"`
KGDedupConfig json.RawMessage `json:"kg_dedup_config,omitempty"`
}
@@ -154,6 +155,7 @@ func marshalAgentConfig(ag *store.AgentData) ([]byte, error) {
ReasoningConfig: ag.ReasoningConfig,
WorkspaceSharing: ag.WorkspaceSharing,
ChatGPTOAuthRouting: canonicalizeChatGPTOAuthRoutingForResponse(ag.ChatGPTOAuthRouting),
ModelFallback: ag.ModelFallback,
ShellDenyGroups: ag.ShellDenyGroups,
KGDedupConfig: ag.KGDedupConfig,
}, "", " ")
+1
View File
@@ -111,6 +111,7 @@ func (h *AgentsHandler) buildAgentFromArchive(cfg map[string]json.RawMessage, ag
ag.ReasoningConfig = rawOrNil(cfg["reasoning_config"])
ag.WorkspaceSharing = rawOrNil(cfg["workspace_sharing"])
ag.ChatGPTOAuthRouting = rawOrNil(cfg["chatgpt_oauth_routing"])
ag.ModelFallback = rawOrNil(cfg["model_fallback"])
ag.ShellDenyGroups = rawOrNil(cfg["shell_deny_groups"])
ag.KGDedupConfig = rawOrNil(cfg["kg_dedup_config"])
+2 -2
View File
@@ -44,7 +44,7 @@ var agentAllowedFields = map[string]bool{
"agent_key": true, "agent_type": true, "display_name": true,
"provider": true, "model": true, "status": true,
"context_window": true, "max_tool_iterations": true,
"workspace": true,
"workspace": true,
"frontmatter": true, "compaction_config": true,
"memory_config": true, "other_config": true, "tools_config": true,
"sandbox_config": true, "context_pruning": true,
@@ -53,7 +53,7 @@ var agentAllowedFields = map[string]bool{
"emoji": true, "agent_description": true, "thinking_level": true, "max_tokens": true,
"self_evolve": true, "skill_evolve": true, "skill_nudge_interval": true,
"reasoning_config": true, "workspace_sharing": true, "chatgpt_oauth_routing": true,
"shell_deny_groups": true, "kg_dedup_config": true,
"model_fallback": true, "shell_deny_groups": true, "kg_dedup_config": true,
}
var providerAllowedFields = map[string]bool{
@@ -48,3 +48,43 @@ func ResolveConfiguredProvider(registry *providers.Registry, agent *store.AgentD
}
return nil, baseErr
}
// ResolveAgentProvider resolves the agent runtime provider, including generic
// per-agent model fallback when configured.
func ResolveAgentProvider(registry *providers.Registry, agent *store.AgentData) (providers.Provider, error) {
baseProvider, err := ResolveConfiguredProvider(registry, agent)
if err != nil {
return nil, err
}
if registry == nil || agent == nil {
return baseProvider, nil
}
fallbackCfg := agent.ParseModelFallback()
if fallbackCfg == nil {
return baseProvider, nil
}
candidates := make([]providers.FallbackCandidate, 0, len(fallbackCfg.Candidates))
for _, candidate := range fallbackCfg.Candidates {
provider, err := registry.GetForTenant(agent.TenantID, candidate.Provider)
if err != nil || provider == nil {
continue
}
candidates = append(candidates, providers.FallbackCandidate{
ProviderName: candidate.Provider,
Model: candidate.Model,
Provider: provider,
})
}
if len(candidates) == 0 {
return baseProvider, nil
}
cooldownEnabled := true
if fallbackCfg.CooldownEnabled != nil {
cooldownEnabled = *fallbackCfg.CooldownEnabled
}
return providers.NewModelFallbackProvider(providers.FallbackCandidate{
ProviderName: agent.Provider,
Model: agent.Model,
Provider: baseProvider,
}, candidates, fallbackCfg.MaxAttempts, cooldownEnabled), nil
}
@@ -226,3 +226,37 @@ func TestResolveConfiguredProviderReturnsRouterEvenWhenPrimaryNeedsFailover(t *t
t.Fatalf("ResolveConfiguredProvider() returned %T, want *providers.ChatGPTOAuthRouter", resolved)
}
}
func TestResolveAgentProviderWrapsModelFallback(t *testing.T) {
tenantID := uuid.New()
registry := providers.NewRegistry(nil)
base := &stubProvider{name: "primary", model: "primary-model"}
backup := &stubProvider{name: "backup", model: "backup-default"}
registry.RegisterForTenant(tenantID, base)
registry.RegisterForTenant(tenantID, backup)
agent := &store.AgentData{
TenantID: tenantID,
Provider: "primary",
Model: "primary-model",
ModelFallback: json.RawMessage(`{
"enabled": true,
"strategy": "priority_order",
"candidates": [
{"provider": "backup", "model": "backup-model"}
]
}`),
}
resolved, err := ResolveAgentProvider(registry, agent)
if err != nil {
t.Fatalf("ResolveAgentProvider() error = %v", err)
}
fallback, ok := resolved.(*providers.ModelFallbackProvider)
if !ok {
t.Fatalf("ResolveAgentProvider() returned %T, want *providers.ModelFallbackProvider", resolved)
}
if fallback.PrimaryProvider() != base {
t.Fatalf("PrimaryProvider() = %T, want original base provider", fallback.PrimaryProvider())
}
}
+153
View File
@@ -0,0 +1,153 @@
package providers
import (
"context"
)
// FallbackCandidate is one runtime provider/model fallback option.
type FallbackCandidate struct {
ProviderName string
Model string
Provider Provider
}
// ModelFallbackProvider wraps a primary provider with ordered fallback
// provider/model candidates. The primary candidate is always tried first.
type ModelFallbackProvider struct {
primary FallbackCandidate
fallbacks []FallbackCandidate
classifier ErrorClassifier
tracker *CooldownTracker
maxAttempts int
}
func NewModelFallbackProvider(primary FallbackCandidate, fallbacks []FallbackCandidate, maxAttempts int, cooldownEnabled bool) *ModelFallbackProvider {
var tracker *CooldownTracker
if cooldownEnabled {
tracker = NewCooldownTracker(0)
}
return &ModelFallbackProvider{
primary: primary,
fallbacks: fallbacks,
classifier: NewDefaultClassifier(),
tracker: tracker,
maxAttempts: maxAttempts,
}
}
func (p *ModelFallbackProvider) PrimaryProvider() Provider {
return p.primary.Provider
}
func (p *ModelFallbackProvider) Name() string {
if p.primary.Provider != nil {
return p.primary.Provider.Name()
}
return p.primary.ProviderName
}
func (p *ModelFallbackProvider) DefaultModel() string {
if p.primary.Model != "" {
return p.primary.Model
}
if p.primary.Provider != nil {
return p.primary.Provider.DefaultModel()
}
return ""
}
func (p *ModelFallbackProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
return p.runOrdered(ctx, req, func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (*ChatResponse, error) {
nextReq := req
nextReq.Model = entry.Model
return entry.Provider.Chat(ctx, nextReq)
})
}
func (p *ModelFallbackProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
return p.runOrdered(ctx, req, func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (*ChatResponse, error) {
nextReq := req
nextReq.Model = entry.Model
streamed := false
resp, err := entry.Provider.ChatStream(ctx, nextReq, func(chunk StreamChunk) {
if chunk.Content != "" || chunk.Thinking != "" || len(chunk.Images) > 0 {
streamed = true
}
onChunk(chunk)
})
if streamed && err != nil {
return nil, noFallbackAfterStreamError{err: err}
}
return resp, err
})
}
func (p *ModelFallbackProvider) runOrdered(
ctx context.Context,
req ChatRequest,
call func(context.Context, FallbackCandidate, ChatRequest) (*ChatResponse, error),
) (*ChatResponse, error) {
candidates := p.orderedCandidates(req.Model)
var attempts []FailoverAttempt
for i, entry := range candidates {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if p.maxAttempts > 0 && i >= p.maxAttempts {
break
}
key := CooldownKey(entry.ProviderName, entry.Model)
if p.tracker != nil && !p.tracker.IsAvailable(key) && !p.tracker.ShouldProbe(key) {
continue
}
resp, err := call(ctx, entry, req)
if err == nil {
if p.tracker != nil {
p.tracker.RecordSuccess(key)
}
return resp, nil
}
if streamErr, ok := err.(noFallbackAfterStreamError); ok {
return nil, streamErr.err
}
classification := ClassifyHTTPError(p.classifier, err)
attempts = append(attempts, FailoverAttempt{
Candidate: ModelCandidate{Provider: entry.ProviderName, Model: entry.Model, ProfileID: entry.ProviderName + "/" + entry.Model},
Classification: classification,
Err: err,
})
if p.tracker != nil && classification.Kind == "reason" {
p.tracker.RecordFailure(key, classification.Reason)
}
if classification.Kind == "context_overflow" || classification.Reason == FailoverUnknown {
return nil, err
}
}
return nil, &FailoverSummaryError{Attempts: attempts}
}
func (p *ModelFallbackProvider) orderedCandidates(requestModel string) []FallbackCandidate {
primary := p.primary
if requestModel != "" {
primary.Model = requestModel
}
out := []FallbackCandidate{primary}
for _, fallback := range p.fallbacks {
if fallback.Provider == nil || fallback.ProviderName == "" || fallback.Model == "" {
continue
}
if fallback.ProviderName == primary.ProviderName && fallback.Model == primary.Model {
continue
}
out = append(out, fallback)
}
return out
}
type noFallbackAfterStreamError struct {
err error
}
func (e noFallbackAfterStreamError) Error() string {
return e.err.Error()
}
+171
View File
@@ -0,0 +1,171 @@
package providers
import (
"context"
"errors"
"testing"
)
type testFallbackProvider struct {
name string
model string
err error
streamErr error
calls int
}
func (p *testFallbackProvider) Chat(_ context.Context, req ChatRequest) (*ChatResponse, error) {
p.calls++
if p.err != nil {
return nil, p.err
}
return &ChatResponse{Content: req.Model, FinishReason: "stop"}, nil
}
func (p *testFallbackProvider) ChatStream(_ context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
p.calls++
if p.streamErr != nil {
if req.Model == "primary-model" {
onChunk(StreamChunk{Content: "partial"})
}
return nil, p.streamErr
}
return &ChatResponse{Content: req.Model, FinishReason: "stop"}, nil
}
func (p *testFallbackProvider) DefaultModel() string { return p.model }
func (p *testFallbackProvider) Name() string { return p.name }
func TestModelFallbackProviderFallsBackOnClassifiedError(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: &HTTPError{Status: 429, Body: "rate limited"},
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 2, false)
resp, err := provider.Chat(context.Background(), ChatRequest{})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "backup-model" {
t.Fatalf("Chat() content = %q, want backup model", resp.Content)
}
if primary.calls != 1 || backup.calls != 1 {
t.Fatalf("calls primary=%d backup=%d, want 1/1", primary.calls, backup.calls)
}
}
func TestModelFallbackProviderDoesNotFallbackAfterStreamChunk(t *testing.T) {
streamErr := &HTTPError{Status: 429, Body: "rate limited"}
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
streamErr: streamErr,
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 2, false)
var chunks int
_, err := provider.ChatStream(context.Background(), ChatRequest{}, func(StreamChunk) {
chunks++
})
if err == nil {
t.Fatal("ChatStream() error = nil, want primary stream error")
}
if chunks != 1 {
t.Fatalf("chunks = %d, want 1", chunks)
}
if backup.calls != 0 {
t.Fatalf("backup calls = %d, want 0 after partial stream", backup.calls)
}
}
func TestModelFallbackProviderFallsBackToSameModelOnDifferentProvider(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "shared-model",
err: &HTTPError{Status: 404, Body: "model not found"},
}
backup := &testFallbackProvider{name: "backup", model: "shared-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "shared-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "shared-model"},
}, 0, false)
resp, err := provider.Chat(context.Background(), ChatRequest{})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "shared-model" {
t.Fatalf("Chat() content = %q, want shared model from backup", resp.Content)
}
if primary.calls != 1 || backup.calls != 1 {
t.Fatalf("calls primary=%d backup=%d, want 1/1", primary.calls, backup.calls)
}
}
func TestModelFallbackProviderDoesNotFallbackOnUnknownError(t *testing.T) {
unknownErr := errors.New("request serialization failed")
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: unknownErr,
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 0, false)
_, err := provider.Chat(context.Background(), ChatRequest{})
if !errors.Is(err, unknownErr) {
t.Fatalf("Chat() error = %v, want original unknown error", err)
}
if backup.calls != 0 {
t.Fatalf("backup calls = %d, want 0 for unknown error", backup.calls)
}
}
func TestModelFallbackProviderMaxAttemptsCapsTotalAttempts(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: &HTTPError{Status: 429, Body: "rate limited"},
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 1, false)
_, err := provider.Chat(context.Background(), ChatRequest{})
if err == nil {
t.Fatal("Chat() error = nil, want exhausted after primary only")
}
if primary.calls != 1 || backup.calls != 0 {
t.Fatalf("calls primary=%d backup=%d, want 1/0", primary.calls, backup.calls)
}
}
+69
View File
@@ -81,6 +81,7 @@ type AgentData struct {
ReasoningConfig json.RawMessage `json:"reasoning_config,omitempty" db:"reasoning_config"`
WorkspaceSharing json.RawMessage `json:"workspace_sharing,omitempty" db:"workspace_sharing"`
ChatGPTOAuthRouting json.RawMessage `json:"chatgpt_oauth_routing,omitempty" db:"chatgpt_oauth_routing"`
ModelFallback json.RawMessage `json:"model_fallback,omitempty" db:"model_fallback"`
ShellDenyGroups json.RawMessage `json:"shell_deny_groups,omitempty" db:"shell_deny_groups"`
KGDedupConfig json.RawMessage `json:"kg_dedup_config,omitempty" db:"kg_dedup_config"`
}
@@ -485,6 +486,74 @@ func (a *AgentData) ParseChatGPTOAuthRouting() *ChatGPTOAuthRoutingConfig {
return routing
}
const (
ModelFallbackStrategyPriority = "priority_order"
)
type ModelFallbackCandidate struct {
Provider string `json:"provider,omitempty" db:"-"`
Model string `json:"model,omitempty" db:"-"`
}
type ModelFallbackConfig struct {
Enabled bool `json:"enabled,omitempty" db:"-"`
Strategy string `json:"strategy,omitempty" db:"-"`
Candidates []ModelFallbackCandidate `json:"candidates,omitempty" db:"-"`
MaxAttempts int `json:"max_attempts,omitempty" db:"-"`
CooldownEnabled *bool `json:"cooldown_enabled,omitempty" db:"-"`
}
func (a *AgentData) ParseModelFallback() *ModelFallbackConfig {
if len(a.ModelFallback) <= 2 {
return nil
}
var raw ModelFallbackConfig
if json.Unmarshal(a.ModelFallback, &raw) != nil || !raw.Enabled {
return nil
}
cfg := NormalizeModelFallbackConfig(&raw)
if cfg == nil || len(cfg.Candidates) == 0 {
return nil
}
return cfg
}
func NormalizeModelFallbackConfig(cfg *ModelFallbackConfig) *ModelFallbackConfig {
if cfg == nil {
return nil
}
out := &ModelFallbackConfig{
Enabled: cfg.Enabled,
Strategy: cfg.Strategy,
MaxAttempts: cfg.MaxAttempts,
CooldownEnabled: cfg.CooldownEnabled,
}
if out.Strategy == "" {
out.Strategy = ModelFallbackStrategyPriority
}
if out.Strategy != ModelFallbackStrategyPriority {
out.Strategy = ModelFallbackStrategyPriority
}
seen := make(map[string]bool, len(cfg.Candidates))
for _, c := range cfg.Candidates {
c.Provider = strings.TrimSpace(c.Provider)
c.Model = strings.TrimSpace(c.Model)
if c.Provider == "" || c.Model == "" {
continue
}
key := c.Provider + "\x00" + c.Model
if seen[key] {
continue
}
seen[key] = true
out.Candidates = append(out.Candidates, c)
}
if out.MaxAttempts < 0 {
out.MaxAttempts = 0
}
return out
}
func normalizeChatGPTOAuthRoutingConfig(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig {
if cfg == nil {
return nil
+10 -8
View File
@@ -93,7 +93,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro
emoji, agent_description, thinking_level, max_tokens,
self_evolve, skill_evolve, skill_nudge_interval,
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
shell_deny_groups, kg_dedup_config,
model_fallback, shell_deny_groups, kg_dedup_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
@@ -115,10 +115,10 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
emoji, agent_description, thinking_level, max_tokens,
self_evolve, skill_evolve, skill_nudge_interval,
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
shell_deny_groups, kg_dedup_config,
model_fallback, shell_deny_groups, kg_dedup_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37)`,
$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38)`,
agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model,
agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace,
jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig),
@@ -126,7 +126,7 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
agent.Emoji, agent.AgentDescription, agent.ThinkingLevel, agent.MaxTokens,
agent.SelfEvolve, agent.SkillEvolve, agent.SkillNudgeInterval,
jsonOrEmpty(agent.ReasoningConfig), jsonOrEmpty(agent.WorkspaceSharing), jsonOrEmpty(agent.ChatGPTOAuthRouting),
jsonOrEmpty(agent.ShellDenyGroups), jsonOrEmpty(agent.KGDedupConfig),
jsonOrEmpty(agent.ModelFallback), jsonOrEmpty(agent.ShellDenyGroups), jsonOrEmpty(agent.KGDedupConfig),
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, tenantID,
)
if err != nil {
@@ -207,7 +207,7 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str
}
}
// NOT NULL JSONB columns: null → empty object.
for _, col := range []string{"other_config", "tools_config", "chatgpt_oauth_routing", "reasoning_config", "workspace_sharing", "shell_deny_groups", "kg_dedup_config"} {
for _, col := range []string{"other_config", "tools_config", "chatgpt_oauth_routing", "model_fallback", "reasoning_config", "workspace_sharing", "shell_deny_groups", "kg_dedup_config"} {
if v, ok := updates[col]; ok && v == nil {
updates[col] = []byte("{}")
}
@@ -496,13 +496,13 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
var frontmatter sql.NullString
// pgx: scan nullable JSONB into *[]byte (NOT *json.RawMessage — pgx can't scan NULL into defined types)
var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte
var reasoningCfg, wsCfg, oauthCfg, shellCfg, kgCfg *[]byte
var reasoningCfg, wsCfg, oauthCfg, fallbackCfg, shellCfg, kgCfg *[]byte
err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
&d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace,
&toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
&d.Emoji, &d.AgentDescription, &d.ThinkingLevel, &d.MaxTokens,
&d.SelfEvolve, &d.SkillEvolve, &d.SkillNudgeInterval,
&reasoningCfg, &wsCfg, &oauthCfg, &shellCfg, &kgCfg,
&reasoningCfg, &wsCfg, &oauthCfg, &fallbackCfg, &shellCfg, &kgCfg,
&d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt, &d.TenantID)
if err != nil {
return nil, err
@@ -541,6 +541,9 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
if oauthCfg != nil {
d.ChatGPTOAuthRouting = *oauthCfg
}
if fallbackCfg != nil {
d.ModelFallback = *fallbackCfg
}
if shellCfg != nil {
d.ShellDenyGroups = *shellCfg
}
@@ -633,4 +636,3 @@ func replaceIDX(s, replacement string) string {
}
return result.String()
}
+10 -7
View File
@@ -35,7 +35,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro
emoji, agent_description, thinking_level, max_tokens,
self_evolve, skill_evolve, skill_nudge_interval,
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
shell_deny_groups, kg_dedup_config,
model_fallback, shell_deny_groups, kg_dedup_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
@@ -57,9 +57,9 @@ func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) e
emoji, agent_description, thinking_level, max_tokens,
self_evolve, skill_evolve, skill_nudge_interval,
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
shell_deny_groups, kg_dedup_config,
model_fallback, shell_deny_groups, kg_dedup_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
agent.ID, agent.AgentKey,
agent.DisplayName,
sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""},
@@ -70,7 +70,7 @@ func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) e
agent.Emoji, agent.AgentDescription, agent.ThinkingLevel, agent.MaxTokens,
agent.SelfEvolve, agent.SkillEvolve, agent.SkillNudgeInterval,
jsonOrEmpty(agent.ReasoningConfig), jsonOrEmpty(agent.WorkspaceSharing), jsonOrEmpty(agent.ChatGPTOAuthRouting),
jsonOrEmpty(agent.ShellDenyGroups), jsonOrEmpty(agent.KGDedupConfig),
jsonOrEmpty(agent.ModelFallback), jsonOrEmpty(agent.ShellDenyGroups), jsonOrEmpty(agent.KGDedupConfig),
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents,
now, now, tenantID,
)
@@ -151,7 +151,7 @@ func (s *SQLiteAgentStore) Update(ctx context.Context, id uuid.UUID, updates map
}
}
// NOT NULL JSON columns: null → empty object.
for _, col := range []string{"other_config", "tools_config", "reasoning_config", "workspace_sharing", "chatgpt_oauth_routing", "shell_deny_groups", "kg_dedup_config"} {
for _, col := range []string{"other_config", "tools_config", "reasoning_config", "workspace_sharing", "chatgpt_oauth_routing", "model_fallback", "shell_deny_groups", "kg_dedup_config"} {
if v, ok := updates[col]; ok && v == nil {
updates[col] = []byte("{}")
}
@@ -257,7 +257,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
var d store.AgentData
var frontmatter sql.NullString
var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte
var reasoningCfg, wsCfg, oauthCfg, shellCfg, kgCfg *[]byte
var reasoningCfg, wsCfg, oauthCfg, fallbackCfg, shellCfg, kgCfg *[]byte
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
@@ -265,7 +265,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
&toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
&d.Emoji, &d.AgentDescription, &d.ThinkingLevel, &d.MaxTokens,
&d.SelfEvolve, &d.SkillEvolve, &d.SkillNudgeInterval,
&reasoningCfg, &wsCfg, &oauthCfg, &shellCfg, &kgCfg,
&reasoningCfg, &wsCfg, &oauthCfg, &fallbackCfg, &shellCfg, &kgCfg,
&d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents,
createdAt, updatedAt, &d.TenantID,
)
@@ -307,6 +307,9 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
if oauthCfg != nil {
d.ChatGPTOAuthRouting = *oauthCfg
}
if fallbackCfg != nil {
d.ModelFallback = *fallbackCfg
}
if shellCfg != nil {
d.ShellDenyGroups = *shellCfg
}
+6 -3
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 33
const SchemaVersion = 34
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -593,6 +593,9 @@ CREATE INDEX IF NOT EXISTS idx_ws_activity_ws_time ON workstation_activity(w
CREATE INDEX IF NOT EXISTS idx_ws_activity_tenant_time ON workstation_activity(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ws_activity_retention ON workstation_activity(created_at);`,
// Version 33 → 34: per-agent ordered provider/model fallback config.
33: `ALTER TABLE agents ADD COLUMN model_fallback TEXT NOT NULL DEFAULT '{}';`,
// Version 23 → 24: vault_documents scope/ownership consistency triggers.
// Mirrors PG migration 000055 CHECK constraint; SQLite cannot add CHECK via
// ALTER TABLE so we use BEFORE INSERT + BEFORE UPDATE triggers instead.
@@ -764,8 +767,8 @@ CREATE TABLE IF NOT EXISTS tenant_hook_budget (
);`
// backfillV16 populates base_name / path_basename for rows that existed
// before the v15 → v16 migration. Idempotent — re-running on already-filled
// rows is a no-op thanks to the WHERE base_name = '' filter.
// before the v15 -> v16 migration. Idempotent; re-running on already-filled
// rows is a no-op for already-filled base_name values.
func backfillV16(ctx context.Context, db *sql.DB) error {
type row struct{ id, path string }
+1
View File
@@ -126,6 +126,7 @@ CREATE TABLE IF NOT EXISTS agents (
reasoning_config TEXT NOT NULL DEFAULT '{}',
workspace_sharing TEXT NOT NULL DEFAULT '{}',
chatgpt_oauth_routing TEXT NOT NULL DEFAULT '{}',
model_fallback TEXT NOT NULL DEFAULT '{}',
shell_deny_groups TEXT NOT NULL DEFAULT '{}',
kg_dedup_config TEXT NOT NULL DEFAULT '{}',
is_default BOOLEAN NOT NULL DEFAULT 0,
+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 = 64
const RequiredSchemaVersion uint = 65
@@ -0,0 +1,2 @@
ALTER TABLE agents
DROP COLUMN IF EXISTS model_fallback;
@@ -0,0 +1,2 @@
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS model_fallback JSONB NOT NULL DEFAULT '{}'::jsonb;
+13
View File
@@ -788,6 +788,19 @@
"shareSessionsTip": "Allow agent to access conversation sessions across all groups. When off, each user's sessions remain isolated.",
"shareSessionsNote": "Toggling this does not migrate data — existing per-user sessions become inaccessible in shared mode."
},
"modelFallback": {
"title": "Model Fallback",
"description": "Try backup provider/model pairs in priority order when the primary model is unavailable.",
"enabled": "Enabled",
"primary": "Primary",
"providerPlaceholder": "Select provider",
"modelPlaceholder": "Select or type model",
"add": "Add fallback",
"remove": "Remove fallback",
"reorder": "Reorder fallback",
"empty": "No fallback models configured.",
"cooldown": "Skip recently failing routes temporarily"
},
"compaction": {
"title": "Compaction",
"description": "Context window compaction and memory flush settings",
+13
View File
@@ -773,6 +773,19 @@
"shareSessionsTip": "Cho phép agent truy cập các phiên hội thoại trên tất cả các nhóm. Khi tắt, phiên của mỗi người dùng được cô lập riêng.",
"shareSessionsNote": "Bật/tắt không di chuyển dữ liệu — phiên per-user trước đó sẽ không truy cập được trong chế độ shared."
},
"modelFallback": {
"title": "Fallback model",
"description": "Thử các cặp provider/model dự phòng theo thứ tự ưu tiên khi model chính không khả dụng.",
"enabled": "Đã bật",
"primary": "Chính",
"providerPlaceholder": "Chọn provider",
"modelPlaceholder": "Chọn hoặc nhập model",
"add": "Thêm fallback",
"remove": "Xóa fallback",
"reorder": "Đổi thứ tự fallback",
"empty": "Chưa cấu hình model fallback.",
"cooldown": "Tạm bỏ qua route vừa lỗi"
},
"compaction": {
"title": "Nén ngữ cảnh",
"description": "Cài đặt nén cửa sổ ngữ cảnh và ghi nhớ trước nén",
+14 -1
View File
@@ -773,6 +773,19 @@
"shareSessionsTip": "允许 agent 访问所有群组的会话记录。关闭时,每个用户的会话保持隔离。",
"shareSessionsNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立会话将无法访问。"
},
"modelFallback": {
"title": "模型回退",
"description": "主模型不可用时,按优先顺序尝试备用 provider/model。",
"enabled": "已启用",
"primary": "主模型",
"providerPlaceholder": "选择 provider",
"modelPlaceholder": "选择或输入模型",
"add": "添加回退",
"remove": "删除回退",
"reorder": "调整回退顺序",
"empty": "尚未配置回退模型。",
"cooldown": "临时跳过近期失败的路由"
},
"compaction": {
"title": "压缩",
"description": "上下文窗口压缩和压缩前记忆设置",
@@ -986,7 +999,7 @@
"allMembersTitle": "Grant this rule to every member in the selected scope by using userId=\"*\".",
"checkAccess": "Check access",
"allowed": "Allowed",
"denied": "Denied",
"denied": "Denied",
"fileWriters": "文件编辑者",
"configPerms": "配置权限",
"noRules": "暂无权限规则。所有者默认拥有完全访问权限。",
@@ -8,12 +8,12 @@ import {
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import type {
AgentData, ChatGPTOAuthRoutingConfig, CompactionConfig, ContextPruningConfig,
ReasoningOverrideMode,
ModelFallbackConfig, ReasoningOverrideMode,
SandboxConfig, WorkspaceSharingConfig,
} from "@/types/agent";
import {
ChatGPTOAuthRoutingSection, ThinkingSection, WorkspaceSharingSection, CompactionSection,
ContextPruningSection, SandboxSection,
ContextPruningSection, ModelFallbackSection, SandboxSection,
} from "./config-sections";
import { WorkspaceSection } from "./general-sections";
import { useProviders } from "@/pages/providers/hooks/use-providers";
@@ -55,6 +55,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
const [reasoningFallback, setReasoningFallback] = useState<string>(init.reasoningFallback);
const [reasoningExpert, setReasoningExpert] = useState(init.reasoningExpert);
const [chatgptRouting, setChatgptRouting] = useState<ChatGPTOAuthRoutingConfig>(init.chatgptRouting);
const [modelFallback, setModelFallback] = useState<ModelFallbackConfig>(init.modelFallback);
const [comp, setComp] = useState<CompactionConfig>(init.comp);
const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled);
const [prune, setPrune] = useState<ContextPruningConfig>(init.prune);
@@ -72,6 +73,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
setReasoningFallback(s.reasoningFallback);
setReasoningExpert(s.reasoningExpert);
setChatgptRouting(s.chatgptRouting);
setModelFallback(s.modelFallback);
setWsSharing(s.wsSharing);
setComp(s.comp);
setPruneEnabled(s.pruneEnabled);
@@ -124,6 +126,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
reasoningFallback,
thinkingLevel,
chatgptRouting,
modelFallback,
wsSharing,
comp,
pruneEnabled,
@@ -211,6 +214,14 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
}
/>
<ModelFallbackSection
primaryProvider={agent.provider}
primaryModel={agent.model}
providers={providers}
value={modelFallback}
onChange={setModelFallback}
/>
{/* Performance */}
<ConfigGroupHeader
title={t("configGroups.performance")}
@@ -3,6 +3,7 @@ import type {
ChatGPTOAuthRoutingConfig,
CompactionConfig,
ContextPruningConfig,
ModelFallbackConfig,
ReasoningOverrideMode,
SandboxConfig,
WorkspaceSharingConfig,
@@ -29,6 +30,7 @@ export interface AdvancedDialogState {
reasoningFallback: string;
reasoningExpert: boolean;
chatgptRouting: ChatGPTOAuthRoutingConfig;
modelFallback: ModelFallbackConfig;
wsSharing: WorkspaceSharingConfig;
comp: CompactionConfig;
pruneEnabled: boolean;
@@ -90,6 +92,7 @@ export function deriveState(
!SIMPLE_REASONING_LEVELS.has(reasoningEffort) ||
reasoningFallback !== "downgrade"),
chatgptRouting: draftRouting,
modelFallback: agent.model_fallback ?? { enabled: false, strategy: "priority_order", candidates: [] },
// Read workspace_sharing from top-level, fallback to other_config for transition
wsSharing: (
agent.workspace_sharing ??
@@ -116,6 +119,7 @@ export interface BuildAdvancedUpdatePayloadParams {
reasoningFallback: string;
thinkingLevel: string;
chatgptRouting: ChatGPTOAuthRoutingConfig;
modelFallback: ModelFallbackConfig;
wsSharing: WorkspaceSharingConfig;
comp: CompactionConfig;
pruneEnabled: boolean;
@@ -131,7 +135,7 @@ export function buildAdvancedUpdatePayload(
agent, currentProvider, providersLoading, providerModelsLoading,
expertReasoningAvailable, reasoningMode, reasoningEffort, reasoningExpert,
reasoningFallback, thinkingLevel, chatgptRouting, wsSharing,
comp, pruneEnabled, prune, sbEnabled, sb,
modelFallback, comp, pruneEnabled, prune, sbEnabled, sb,
} = params;
const routingPayload = buildAgentOtherConfigWithChatGPTOAuthRouting(
@@ -149,6 +153,7 @@ export function buildAdvancedUpdatePayload(
? { mode: "cache-ttl", ...prune }
: { mode: "off" },
sandbox_config: sbEnabled ? sb : null,
model_fallback: normalizeModelFallbackForPayload(modelFallback),
...routingPayload,
};
@@ -186,3 +191,19 @@ export function buildAdvancedUpdatePayload(
return updates;
}
function normalizeModelFallbackForPayload(config: ModelFallbackConfig): ModelFallbackConfig {
const candidates = (config.candidates ?? [])
.map((candidate) => ({
provider: candidate.provider?.trim() ?? "",
model: candidate.model?.trim() ?? "",
}))
.filter((candidate) => candidate.provider && candidate.model);
return {
enabled: Boolean(config.enabled && candidates.length > 0),
strategy: "priority_order",
candidates,
...(config.max_attempts && config.max_attempts > 0 ? { max_attempts: config.max_attempts } : {}),
cooldown_enabled: config.cooldown_enabled ?? true,
};
}
@@ -8,3 +8,4 @@ export { MemorySection } from "./memory-section";
export { ThinkingSection } from "./thinking-section";
export { WorkspaceSharingSection } from "./workspace-sharing-section";
export { ChatGPTOAuthRoutingSection } from "./chatgpt-oauth-routing-section";
export { ModelFallbackSection } from "./model-fallback-section";
@@ -0,0 +1,97 @@
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import type { ModelFallbackCandidate } from "@/types/agent";
import type { ProviderData } from "@/types/provider";
interface SortableFallbackRowProps {
id: string;
candidate: ModelFallbackCandidate;
providers: ProviderData[];
onChange: (candidate: ModelFallbackCandidate) => void;
onRemove: () => void;
}
function providerLabel(provider: ProviderData): string {
return provider.display_name || provider.name;
}
export function SortableFallbackRow({
id,
candidate,
providers,
onChange,
onRemove,
}: SortableFallbackRowProps) {
const { t } = useTranslation("agents");
const selectedProvider = providers.find((provider) => provider.name === candidate.provider);
const { models } = useProviderModels(selectedProvider?.id);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const modelOptions = models.map((model) => ({
value: model.id,
label: model.name || model.id,
}));
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={`grid gap-2 rounded-md border bg-background p-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto] ${
isDragging ? "shadow-md" : ""
}`}
>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 cursor-grab text-muted-foreground active:cursor-grabbing"
aria-label={t("configSections.modelFallback.reorder")}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</Button>
<Combobox
value={candidate.provider ?? ""}
onChange={(provider) => onChange({ provider, model: "" })}
options={providers.map((provider) => ({
value: provider.name,
label: providerLabel(provider),
}))}
placeholder={t("configSections.modelFallback.providerPlaceholder")}
/>
<Combobox
value={candidate.model ?? ""}
onChange={(model) => onChange({ ...candidate, model })}
options={modelOptions}
placeholder={t("configSections.modelFallback.modelPlaceholder")}
allowCustom
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
aria-label={t("configSections.modelFallback.remove")}
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,162 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
DndContext,
PointerSensor,
KeyboardSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { ModelFallbackCandidate, ModelFallbackConfig } from "@/types/agent";
import type { ProviderData } from "@/types/provider";
import { SortableFallbackRow } from "./model-fallback-row";
interface ModelFallbackSectionProps {
primaryProvider: string;
primaryModel: string;
providers: ProviderData[];
value: ModelFallbackConfig;
onChange: (value: ModelFallbackConfig) => void;
}
export function ModelFallbackSection({
primaryProvider,
primaryModel,
providers,
value,
onChange,
}: ModelFallbackSectionProps) {
const { t } = useTranslation("agents");
const candidates = value.candidates ?? [];
const enabledProviders = useMemo(() => {
const selectedNames = new Set(candidates.map((candidate) => candidate.provider));
return providers.filter((provider) => provider.enabled || selectedNames.has(provider.name));
}, [candidates, providers]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const itemIds = candidates.map((_, index) => `fallback-${index}`);
const updateCandidate = (index: number, candidate: ModelFallbackCandidate) => {
const next = [...candidates];
next[index] = candidate;
onChange({ ...value, candidates: next });
};
const removeCandidate = (index: number) => {
const next = candidates.filter((_, candidateIndex) => candidateIndex !== index);
onChange({ ...value, enabled: value.enabled && next.length > 0, candidates: next });
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = itemIds.indexOf(String(active.id));
const newIndex = itemIds.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
onChange({ ...value, candidates: arrayMove(candidates, oldIndex, newIndex) });
};
return (
<section className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-medium">{t("configSections.modelFallback.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("configSections.modelFallback.description")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor="agent-model-fallback" className="text-xs text-muted-foreground">
{t("configSections.modelFallback.enabled")}
</Label>
<Switch
id="agent-model-fallback"
checked={Boolean(value.enabled)}
onCheckedChange={(enabled) => onChange({ ...value, enabled })}
/>
</div>
</div>
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
<div className="grid gap-2 rounded-md border bg-muted/30 p-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)]">
<Badge variant="secondary" className="h-6 w-fit">
{t("configSections.modelFallback.primary")}
</Badge>
<div className="min-w-0 truncate text-sm">{primaryProvider}</div>
<div className="min-w-0 truncate text-sm text-muted-foreground">{primaryModel}</div>
</div>
{candidates.length > 0 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{candidates.map((candidate, index) => {
const id = itemIds[index] ?? `fallback-${index}`;
return (
<SortableFallbackRow
key={id}
id={id}
candidate={candidate}
providers={enabledProviders}
onChange={(next) => updateCandidate(index, next)}
onRemove={() => removeCandidate(index)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
) : (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t("configSections.modelFallback.empty")}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Switch
checked={value.cooldown_enabled ?? true}
onCheckedChange={(cooldownEnabled) =>
onChange({ ...value, cooldown_enabled: cooldownEnabled })
}
/>
<Label className="text-xs text-muted-foreground">
{t("configSections.modelFallback.cooldown")}
</Label>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange({
...value,
enabled: true,
strategy: "priority_order",
candidates: [...candidates, { provider: "", model: "" }],
})
}
>
<Plus className="h-4 w-4" />
{t("configSections.modelFallback.add")}
</Button>
</div>
</div>
</section>
);
}
+14
View File
@@ -117,6 +117,19 @@ export interface ChatGPTOAuthRoutingConfig {
extra_provider_names?: string[];
}
export interface ModelFallbackCandidate {
provider?: string;
model?: string;
}
export interface ModelFallbackConfig {
enabled?: boolean;
strategy?: "priority_order";
candidates?: ModelFallbackCandidate[];
max_attempts?: number;
cooldown_enabled?: boolean;
}
export interface KgDedupConfig {
enabled?: boolean;
similarity_threshold?: number;
@@ -156,6 +169,7 @@ export interface AgentData {
reasoning_config?: AgentReasoningConfig | null;
workspace_sharing?: WorkspaceSharingConfig | null;
chatgpt_oauth_routing?: ChatGPTOAuthRoutingConfig | null;
model_fallback?: ModelFallbackConfig | null;
shell_deny_groups?: ShellDenyGroups | null;
kg_dedup_config?: KgDedupConfig | null;