From 506e96cd632008486a4b37bfe6f06ab4875df223 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 17 May 2026 15:51:56 +0700 Subject: [PATCH] feat(agents): add model fallback configuration --- docs/02-providers.md | 10 + docs/06-store-data-model.md | 17 ++ docs/project-changelog.md | 53 +++++- internal/agent/loop_pipeline_adapter.go | 4 + internal/agent/resolver.go | 6 +- internal/http/agents.go | 38 ++++ internal/http/agents_export_marshal.go | 2 + internal/http/agents_import_agent.go | 1 + internal/http/validate.go | 4 +- internal/providerresolve/agent_provider.go | 40 ++++ .../providerresolve/agent_provider_test.go | 34 ++++ internal/providers/model_fallback.go | 153 ++++++++++++++++ internal/providers/model_fallback_test.go | 171 ++++++++++++++++++ internal/store/agent_store.go | 69 +++++++ internal/store/pg/agents.go | 18 +- internal/store/sqlitestore/agents.go | 17 +- internal/store/sqlitestore/schema.go | 9 +- internal/store/sqlitestore/schema.sql | 1 + internal/upgrade/version.go | 2 +- .../000065_agent_model_fallback.down.sql | 2 + migrations/000065_agent_model_fallback.up.sql | 2 + ui/web/src/i18n/locales/en/agents.json | 13 ++ ui/web/src/i18n/locales/vi/agents.json | 13 ++ ui/web/src/i18n/locales/zh/agents.json | 15 +- .../agent-detail/agent-advanced-dialog.tsx | 15 +- .../agent-advanced-state-utils.ts | 23 ++- .../agent-detail/config-sections/index.ts | 1 + .../config-sections/model-fallback-row.tsx | 97 ++++++++++ .../model-fallback-section.tsx | 162 +++++++++++++++++ ui/web/src/types/agent.ts | 14 ++ 30 files changed, 974 insertions(+), 32 deletions(-) create mode 100644 internal/providers/model_fallback.go create mode 100644 internal/providers/model_fallback_test.go create mode 100644 migrations/000065_agent_model_fallback.down.sql create mode 100644 migrations/000065_agent_model_fallback.up.sql create mode 100644 ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-row.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-section.tsx diff --git a/docs/02-providers.md b/docs/02-providers.md index cfae5c8b..df9893a8 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -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 diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index 396095a5..eddc0118 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -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. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index f1e3040e..397b99ab 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -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 diff --git a/internal/agent/loop_pipeline_adapter.go b/internal/agent/loop_pipeline_adapter.go index f0cb3e67..ae6449a5 100644 --- a/internal/agent/loop_pipeline_adapter.go +++ b/internal/agent/loop_pipeline_adapter.go @@ -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) diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 5b62fc31..afd3a936 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -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, diff --git a/internal/http/agents.go b/internal/http/agents.go index 376964c3..44a56fc9 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -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. diff --git a/internal/http/agents_export_marshal.go b/internal/http/agents_export_marshal.go index 0dcd2e11..0613d6c8 100644 --- a/internal/http/agents_export_marshal.go +++ b/internal/http/agents_export_marshal.go @@ -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, }, "", " ") diff --git a/internal/http/agents_import_agent.go b/internal/http/agents_import_agent.go index cbd8e872..4ba9dea4 100644 --- a/internal/http/agents_import_agent.go +++ b/internal/http/agents_import_agent.go @@ -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"]) diff --git a/internal/http/validate.go b/internal/http/validate.go index eb3d8ed5..cc35540e 100644 --- a/internal/http/validate.go +++ b/internal/http/validate.go @@ -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{ diff --git a/internal/providerresolve/agent_provider.go b/internal/providerresolve/agent_provider.go index 9b3e55aa..ee09bd2a 100644 --- a/internal/providerresolve/agent_provider.go +++ b/internal/providerresolve/agent_provider.go @@ -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 +} diff --git a/internal/providerresolve/agent_provider_test.go b/internal/providerresolve/agent_provider_test.go index cbb08c9e..877e641b 100644 --- a/internal/providerresolve/agent_provider_test.go +++ b/internal/providerresolve/agent_provider_test.go @@ -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()) + } +} diff --git a/internal/providers/model_fallback.go b/internal/providers/model_fallback.go new file mode 100644 index 00000000..9df97e03 --- /dev/null +++ b/internal/providers/model_fallback.go @@ -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() +} diff --git a/internal/providers/model_fallback_test.go b/internal/providers/model_fallback_test.go new file mode 100644 index 00000000..5bea45bb --- /dev/null +++ b/internal/providers/model_fallback_test.go @@ -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) + } +} diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 8590d2e4..a8ed8b04 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -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 diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 25aff349..30208214 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -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() } - diff --git a/internal/store/sqlitestore/agents.go b/internal/store/sqlitestore/agents.go index 45f7ff80..3586259b 100644 --- a/internal/store/sqlitestore/agents.go +++ b/internal/store/sqlitestore/agents.go @@ -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 } diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index cf126924..b06ba9b3 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -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 } diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 77b32d70..b3d749c5 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -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, diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index e68a3d12..ab3b06d7 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -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 diff --git a/migrations/000065_agent_model_fallback.down.sql b/migrations/000065_agent_model_fallback.down.sql new file mode 100644 index 00000000..87a128f0 --- /dev/null +++ b/migrations/000065_agent_model_fallback.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE agents +DROP COLUMN IF EXISTS model_fallback; diff --git a/migrations/000065_agent_model_fallback.up.sql b/migrations/000065_agent_model_fallback.up.sql new file mode 100644 index 00000000..cdb42c3f --- /dev/null +++ b/migrations/000065_agent_model_fallback.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE agents +ADD COLUMN IF NOT EXISTS model_fallback JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 5d306e81..e04f3eb9 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 5adb34b0..eb84c04d 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 4dfb4e53..fab6c365 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -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": "暂无权限规则。所有者默认拥有完全访问权限。", diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx index 5b70f486..c1fd3196 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx @@ -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(init.reasoningFallback); const [reasoningExpert, setReasoningExpert] = useState(init.reasoningExpert); const [chatgptRouting, setChatgptRouting] = useState(init.chatgptRouting); + const [modelFallback, setModelFallback] = useState(init.modelFallback); const [comp, setComp] = useState(init.comp); const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled); const [prune, setPrune] = useState(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 } /> + + {/* Performance */} ({ + 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, + }; +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/index.ts b/ui/web/src/pages/agents/agent-detail/config-sections/index.ts index cd86c58a..40563194 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/index.ts +++ b/ui/web/src/pages/agents/agent-detail/config-sections/index.ts @@ -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"; diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-row.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-row.tsx new file mode 100644 index 00000000..5b24492c --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-row.tsx @@ -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 ( +
+ + onChange({ provider, model: "" })} + options={providers.map((provider) => ({ + value: provider.name, + label: providerLabel(provider), + }))} + placeholder={t("configSections.modelFallback.providerPlaceholder")} + /> + onChange({ ...candidate, model })} + options={modelOptions} + placeholder={t("configSections.modelFallback.modelPlaceholder")} + allowCustom + /> + +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-section.tsx new file mode 100644 index 00000000..8bb5937c --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/config-sections/model-fallback-section.tsx @@ -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 ( +
+
+
+

{t("configSections.modelFallback.title")}

+

+ {t("configSections.modelFallback.description")} +

+
+
+ + onChange({ ...value, enabled })} + /> +
+
+ +
+
+ + {t("configSections.modelFallback.primary")} + +
{primaryProvider}
+
{primaryModel}
+
+ + {candidates.length > 0 ? ( + + +
+ {candidates.map((candidate, index) => { + const id = itemIds[index] ?? `fallback-${index}`; + return ( + updateCandidate(index, next)} + onRemove={() => removeCandidate(index)} + /> + ); + })} +
+
+
+ ) : ( +
+ {t("configSections.modelFallback.empty")} +
+ )} + +
+
+ + onChange({ ...value, cooldown_enabled: cooldownEnabled }) + } + /> + +
+ +
+
+
+ ); +} diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts index 5061ba60..46861343 100644 --- a/ui/web/src/types/agent.ts +++ b/ui/web/src/types/agent.ts @@ -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;