mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(agents): add model fallback configuration
This commit is contained in:
1 parent
5a189330f9
commit
506e96cd63
30 files changed
+974
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}, "", " ")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user