feat(llm): LLM 配置轮询(故障转移),某个配置没额度/失效时自动切下一个

默认关闭。开启后「未指定模型」的 agent 在当前配置不可用(402 余额不足 /
401 key 失效 / 429 限流 / 5xx)时自动切到下一个配置;agent 绑定或任务 pin
了配置的默认不轮询(绑定即独占,可由 llm_pool_bind_fallback 打开兜底)。

链序 = 激活配置 + 其余按 priority DESC,排除 pool_exclude 的。只给
llm_profiles 加两列,不引入关联表,所以 agents/tasks/conversations 对
llm_profile_id 的四处引用一行未改。

核心约束:只在本次调用尚未 yield 出任何事件时切换成员。已经吐出
text_delta / tool_use_start 后中途断流一律原样上抛(交给 agentcore 的
resume),否则重发同一 request 会重复输出、污染会话历史。SDK 的 doStream
保证 4xx/5xx 在读 body 前返回,所以目标场景都落在这个安全窗口内。

context.Canceled / DeadlineExceeded 绝不触发切换——那是用户取消或任务
超时,误判会白烧备用 key 并污染 a6bd180 的终止原因诊断。400 也不切
(请求本身有问题,换模型结果一样)。

熔断:402/401/403/404 一次即熔断,429/5xx/网络错误连续 3 次;冷却
60s→5min→30min 退避,到期半开试探,成功清零。Registry 是进程级的,独立
于 provider 缓存,重建链路不丢状态,且全进程共享(A 任务撞见的 402,B 任务
立刻知道)。状态旁路落库,重启只恢复尚未到期的冷却窗口。

上下文窗口装不下当前请求的成员直接跳过(必然 400),不把主力模型的窗口
压到全链最小值。全员熔断时仍探链首,不让引擎停摆。

Pool 包在 llmrec 外面、每个成员自带 recorder,失败调用会以 status=error
+ 正确的 profile 名进 llm_records;切换/熔断走 log.Printf,自动出现在
系统日志页。

顺带修两个既有问题:
- DecomposeGoals 原本自己 NewProvider(),绕过限速器/录制/轮询,且每个
  任务新建一个实例——改为由 server 传入共享 provider
- saveLLMConfig 会把 priority/pool_exclude 重置为零值

前端:LLM 页新增轮询卡片(总开关/兜底开关/实际链路顺序/健康徽章/冷却
倒计时/立即恢复),配置编辑区加优先级与「不参与轮询」。

只有一个配置时链长度为 1,poolChain 返回 nil 走裸 provider,行为与开关
打开前完全一致。设计见 docs/LLM轮询设计.md。
This commit is contained in:
Autumn-27 committed 2026-08-19 11:07:53 -04:00
1 parent 89c5e9b26f
commit 0c653a2135
19 files changed
+1750 -50

No files matched your search

+6
View File
@@ -436,6 +436,10 @@ type LLMProfileDTO struct {
ContextWindowK int `json:"context_window_k"`
ReasoningEffort string `json:"reasoning_effort"`
IsDefault bool `json:"is_default"`
// 轮询(故障转移)参数:priority 越大越先被选中(激活配置恒为链首);
// pool_exclude=true 则不作为故障转移目标,但仍可被 agent/任务显式绑定。
Priority int `json:"priority"`
PoolExclude bool `json:"pool_exclude"`
}
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
@@ -452,6 +456,8 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
ContextWindowK: p.ContextWindowK,
ReasoningEffort: p.ReasoningEffort,
IsDefault: p.IsDefault,
Priority: p.Priority,
PoolExclude: p.PoolExclude,
}
}
+14 -9
View File
@@ -10,6 +10,7 @@ import (
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
)
type goalSpec struct {
@@ -188,7 +189,7 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
// so FromEnv returned empty and every task silently fell back to the crude rule
// splitter (which shredded URLs / made meaningless 2-way splits).
var specs []goalSpec
if cfg, ok := s.goalsLLMConfig(t); ok {
if prov, ok := s.goalsProvider(t); ok {
var as *db.AssetStore
if s.m != nil {
as = s.m.Assets()
@@ -197,7 +198,7 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
// DecomposeGoals persists each decomposed goal via the set_goals tool straight
// into t.Store; it returns the specs it wrote so we can emit activity + spot the
// empty (LLM error / no provider) case below. No write-back needed here anymore.
for _, g := range agent.DecomposeGoals(ctx, cfg, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit) {
for _, g := range agent.DecomposeGoals(ctx, prov, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit) {
if strings.TrimSpace(g.Text) != "" {
specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
}
@@ -220,18 +221,22 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
return specs
}
// goalsLLMConfig resolves the LLM config for goal decomposition by the standard
// precedence: the goals agent's own binding → the task's pinned profile → the global
// active profile (same source the engine runs on).
func (s *Server) goalsLLMConfig(t *Task) (agent.Config, bool) {
// goalsProvider resolves the provider for goal decomposition by the standard
// precedence: the goals agent's own binding → the task's pinned profile → the
// global active provider (the very instance the engine runs on, so decomposition
// shares its rate limiter, gets recorded, and follows LLM failover).
func (s *Server) goalsProvider(t *Task) (llm.Provider, bool) {
var pin *int64
if t != nil {
pin = t.LLMProfileID
}
if eff := s.effectiveProfileForAgent("goals", pin); eff != nil {
if cfg, ok := s.loadProfileConfig(*eff); ok {
return cfg, true
if prov, cfg, ok := s.providerForProfile(*eff); ok {
return s.poolForBinding(*eff, prov, cfg), true
}
}
return s.loadLLMConfig()
s.cfgMu.Lock()
prov, on := s.llmProv, s.llmOn
s.cfgMu.Unlock()
return prov, on && prov != nil
}
+239
View File
@@ -0,0 +1,239 @@
package server
import (
"log"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/llmpool"
"github.com/Autumn-27/norma/llm"
)
// LLM 轮询(故障转移)的服务端接线。设计见 docs/LLM轮询设计.md:
// - 全局激活配置这条路径(agent 未绑定、任务未 pin)才轮询;
// - 绑定/pin 的路径默认独占该配置,失败即失败(可由 llm_pool_bind_fallback 打开兜底);
// - 链序 = 激活配置 → 其余按 priority DESC,排除 pool_exclude 的;
// - 熔断状态进程级共享(s.llmHealth),重建 pool 不清空。
// newLLMHealthRegistry builds the process-wide circuit-breaker registry, mirroring
// state into PG so a cooling-off window survives a restart. Writes are async and
// best-effort — the in-memory copy is authoritative.
func newLLMHealthRegistry(pg *db.DB) *llmpool.Registry {
if pg == nil {
return llmpool.NewRegistry(nil, nil)
}
persist := func(id int64, st llmpool.State) {
h := db.LLMHealth{ProfileID: id, Fails: st.Fails, Trips: st.Trips, LastError: st.LastError}
if !st.OpenUntil.IsZero() {
t := st.OpenUntil
h.OpenUntil = &t
}
go func() {
if err := pg.SaveLLMHealth(h); err != nil {
log.Printf("[llmpool] 熔断状态落库失败: %v", err)
}
}()
}
forget := func(id int64) {
go func() { _ = pg.ClearLLMHealth(id) }()
}
reg := llmpool.NewRegistry(persist, forget)
// Restore only windows that haven't expired (LoadLLMHealth filters), so a
// profile that finished cooling while we were down comes back healthy.
if rows, err := pg.LoadLLMHealth(); err == nil {
for _, h := range rows {
st := llmpool.State{Fails: h.Fails, Trips: h.Trips, LastError: h.LastError, LastAt: h.LastAt}
if h.OpenUntil != nil {
st.OpenUntil = *h.OpenUntil
}
reg.Restore(h.ProfileID, st)
log.Printf("[llmpool] 恢复熔断状态: 配置 #%d 冷却至 %s", h.ProfileID, st.OpenUntil.Format(time.RFC3339))
}
}
return reg
}
// poolMember builds one chain member from a profile, reusing the per-profile
// provider cache so every agent pointing at the same profile shares one provider
// (hence one rate limiter). Returns nil when the profile can't be built.
func (s *Server) poolMember(p *db.LLMProfile, rank int) *llmpool.Member {
prov, cfg, ok := s.providerForProfile(p.ID)
if !ok {
return nil
}
return &llmpool.Member{
ID: p.ID, Name: p.Name, Model: p.Model, Format: p.Format,
Priority: p.Priority, Active: p.IsDefault, Rank: rank,
WindowTokens: cfg.CompactionWindow(), Prov: prov,
}
}
// poolChain reads the failover chain from the DB. headID/headProv/headCfg describe
// the member that must lead the chain — the globally active profile on the normal
// path, or an explicitly bound one when bound-profile fallback is on. The head is
// passed in (rather than resolved through the cache) so applyLLM's already-built
// provider is reused instead of duplicated.
//
// Returns nil when failover is off or the chain has fewer than two usable members
// — callers then use the bare provider, which is byte-for-byte the old behavior.
func (s *Server) poolChain(headID int64, headProv llm.Provider, headCfg agent.Config) *llmpool.Pool {
if s.m == nil || s.m.pg == nil || !s.m.LLMPoolEnabled() {
return nil
}
profs, err := s.m.pg.PoolProfiles()
if err != nil {
log.Printf("[llmpool] 读取轮询链失败: %v", err)
return nil
}
var head *db.LLMProfile
for _, p := range profs {
if p.ID == headID {
head = p
break
}
}
if head == nil { // head excluded from the chain (bound + pool_exclude) — still leads
if p, err := s.m.pg.ProfileByID(headID); err == nil && p != nil {
head = p
} else {
return nil
}
}
members := []*llmpool.Member{{
ID: head.ID, Name: head.Name, Model: head.Model, Format: head.Format,
Priority: head.Priority, Active: head.IsDefault, Rank: llmpool.RankActive,
WindowTokens: headCfg.CompactionWindow(), Prov: headProv,
}}
for _, p := range profs {
if p.ID == headID {
continue
}
// The globally active profile outranks the others when it isn't the head
// (bound-fallback chains), so it stays the first fallback tried.
rank := p.Priority
if p.IsDefault {
rank = llmpool.RankActive - 1
}
if m := s.poolMember(p, rank); m != nil {
members = append(members, m)
}
}
if len(members) < 2 {
return nil // nothing to fail over to
}
return llmpool.New(members, s.llmHealth)
}
// poolForActive wraps the globally-active provider in the failover chain. Returns
// prov unchanged when failover is off or there's nothing to fall back to.
func (s *Server) poolForActive(activeID int64, prov llm.Provider, cfg agent.Config) llm.Provider {
pool := s.poolChain(activeID, prov, cfg)
if pool == nil {
return prov
}
names := make([]string, 0, len(pool.Members()))
for _, m := range pool.Members() {
names = append(names, m.Name+"/"+m.Model)
}
log.Printf("[llmpool] LLM 轮询已启用,链路(%d): %v", len(names), names)
return pool
}
// poolForBinding wraps a BOUND profile's provider so it falls back to the chain.
// Only active when both the failover master switch and the bound-fallback switch
// are on; otherwise a bound profile stays exclusive (fails hard), which is the
// default and the documented precedence.
func (s *Server) poolForBinding(id int64, prov llm.Provider, cfg agent.Config) llm.Provider {
if s.m == nil || !s.m.LLMPoolEnabled() || !s.m.LLMPoolBindFallback() {
return prov
}
if pool := s.poolChain(id, prov, cfg); pool != nil {
return pool
}
return prov
}
// LLMPoolMemberStatus is one chain entry as shown in the UI.
type LLMPoolMemberStatus struct {
ProfileID string `json:"profile_id"`
Name string `json:"name"`
Model string `json:"model"`
Format string `json:"format"`
Priority int `json:"priority"`
Active bool `json:"active"` // is the globally-activated profile
Excluded bool `json:"excluded"` // pool_exclude — not a failover target
// Health: state is "ok" | "degraded" (failing but not tripped) | "tripped".
State string `json:"state"`
Fails int `json:"fails"`
Trips int `json:"trips"`
CooldownSecs int `json:"cooldown_secs"` // remaining cooling-off seconds; 0 = none
LastError string `json:"last_error,omitempty"`
LastAt string `json:"last_at,omitempty"`
}
// llmPoolStatus reports the whole picture for the LLM page: whether failover is
// on, the resolved chain order, and each profile's breaker state. Excluded
// profiles are listed too (flagged), so the user can see why one isn't in line.
func (s *Server) llmPoolStatus() map[string]any {
out := map[string]any{
"enabled": false,
"bind_fallback": false,
"chain": []LLMPoolMemberStatus{},
}
if s.m == nil || s.m.pg == nil {
return out
}
out["enabled"] = s.m.LLMPoolEnabled()
out["bind_fallback"] = s.m.LLMPoolBindFallback()
all, err := s.m.pg.ListProfiles()
if err != nil {
return out
}
health := s.llmHealth.Snapshot()
now := time.Now()
// Chain order: active first, then priority DESC / id ASC — the same ordering
// PoolProfiles applies, recomputed here so excluded ones can be shown in place.
chain := make([]LLMPoolMemberStatus, 0, len(all))
for _, p := range all {
st := health[p.ID]
m := LLMPoolMemberStatus{
ProfileID: i64s(p.ID), Name: p.Name, Model: p.Model, Format: p.Format,
Priority: p.Priority, Active: p.IsDefault, Excluded: p.PoolExclude,
State: "ok", Fails: st.Fails, Trips: st.Trips,
LastError: st.LastError,
}
if st.Open() {
m.State = "tripped"
m.CooldownSecs = int(st.OpenUntil.Sub(now).Seconds()) + 1
} else if st.Fails > 0 {
m.State = "degraded"
}
if !st.LastAt.IsZero() {
m.LastAt = st.LastAt.Format(time.RFC3339)
}
chain = append(chain, m)
}
sortPoolStatus(chain)
out["chain"] = chain
return out
}
// sortPoolStatus orders the status rows exactly like the live chain: active first,
// then priority DESC, then id ASC (the incoming slice is already id-ordered, so a
// stable insertion sort on the first two keys is enough).
func sortPoolStatus(in []LLMPoolMemberStatus) {
for i := 1; i < len(in); i++ {
for j := i; j > 0 && poolLess(in[j], in[j-1]); j-- {
in[j], in[j-1] = in[j-1], in[j]
}
}
}
func poolLess(a, b LLMPoolMemberStatus) bool {
if a.Active != b.Active {
return a.Active
}
return a.Priority > b.Priority
}
+41
View File
@@ -0,0 +1,41 @@
package server
import "testing"
// The status list drives the "轮询顺序" strip in the UI, so it has to match the
// order PoolProfiles actually runs: active first, then priority DESC, then id ASC
// (input arrives id-ordered, so equal priorities must keep their relative order).
func TestSortPoolStatusMatchesChainOrder(t *testing.T) {
in := []LLMPoolMemberStatus{
{ProfileID: "1", Name: "low", Priority: 0},
{ProfileID: "2", Name: "active", Priority: 0, Active: true},
{ProfileID: "3", Name: "high", Priority: 10},
{ProfileID: "4", Name: "mid-a", Priority: 5},
{ProfileID: "5", Name: "mid-b", Priority: 5},
}
sortPoolStatus(in)
want := []string{"active", "high", "mid-a", "mid-b", "low"}
for i, w := range want {
if in[i].Name != w {
got := make([]string, len(in))
for j, m := range in {
got[j] = m.Name
}
t.Fatalf("order = %v, want %v", got, want)
}
}
}
// The active profile heads the chain no matter how low its own priority is —
// that's the documented precedence, and the UI must not imply otherwise.
func TestActiveProfileHeadsStatusList(t *testing.T) {
in := []LLMPoolMemberStatus{
{ProfileID: "1", Name: "loud", Priority: 999},
{ProfileID: "2", Name: "active", Priority: -5, Active: true},
}
sortPoolStatus(in)
if in[0].Name != "active" {
t.Fatalf("head = %q, want the active profile regardless of priority", in[0].Name)
}
}
+36
View File
@@ -86,6 +86,12 @@ const (
settingWebSearchProxy = "web_search_proxy"
settingWorkers = "workers"
settingLLMRecord = "llm_record"
// LLM 轮询(故障转移)。默认关闭——开启后走「全局激活配置」的 agent 在当前配置
// 不可用(余额不足/key 失效/限流/服务异常)时自动切到下一个配置。
// settingLLMPoolBindFallback 仅在轮询开启时有意义:默认关闭,即 agent/任务显式
// 绑定了某个配置就只用它、失败即失败;开启后绑定的配置失败也会回落到轮询链。
settingLLMPoolOn = "llm_pool_enabled"
settingLLMPoolBindFallback = "llm_pool_bind_fallback"
// 任务并发上限:开关 + 上限数。默认关闭;开启后默认上限 5(见 defaultConcurrencyLimit)。
settingConcurrencyOn = "task_concurrency_enabled"
settingConcurrencyLimit = "task_concurrency_limit"
@@ -266,6 +272,36 @@ func (m *Manager) SetLLMRecordEnabled(on bool) error {
return nil
}
// LLMPoolEnabled reports whether LLM failover ("轮询") is on (默认关;
// settings.llm_pool_enabled). Read when the provider chain is built (applyLLM),
// so a change requires a rebuild — putSettings does that.
func (m *Manager) LLMPoolEnabled() bool {
if m.pg == nil {
return false
}
return m.pg.GetBool(settingLLMPoolOn, false)
}
// SetLLMPoolEnabled persists the failover toggle. Callers rebuild agents
// (applyLLM) afterwards so it takes effect.
func (m *Manager) SetLLMPoolEnabled(on bool) error { return m.pg.SetBool(settingLLMPoolOn, on) }
// LLMPoolBindFallback reports whether an agent/task that is BOUND to a specific
// profile still falls back to the chain when that profile fails (默认关:绑定即
// 独占,失败即失败). Only meaningful while LLMPoolEnabled.
func (m *Manager) LLMPoolBindFallback() bool {
if m.pg == nil {
return false
}
return m.pg.GetBool(settingLLMPoolBindFallback, false)
}
// SetLLMPoolBindFallback persists the bound-profile fallback toggle. Callers
// rebuild agents (applyLLM) afterwards.
func (m *Manager) SetLLMPoolBindFallback(on bool) error {
return m.pg.SetBool(settingLLMPoolBindFallback, on)
}
// WebSearch returns the current web-search config: whether it is enabled, the
// backend ("ddgs" | "brave-free" | "tavily"), the Brave API key, the Tavily API
// key (each empty unless set), and the dedicated egress proxy (empty = direct).
+65 -5
View File
@@ -19,6 +19,7 @@ import (
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/llmpool"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/artex/report"
"github.com/Autumn-27/norma/llm"
@@ -53,6 +54,11 @@ type Server struct {
llmCfg agent.Config // current LLM config (key not exposed)
llmOn bool
llmProf string // active LLM profile name (for llmrec tagging)
// llmProv is the fully-decorated global provider (recorder + failover chain)
// applyLLM installed. Kept so one-off callers (goal decomposition) reuse the
// same instance instead of building a private one that skips the rate limiter,
// the recorder and failover.
llmProv llm.Provider
// chatBusy guards the per-task main-agent run: the chat handler launches the
// agent on the server's background ctx (not the request ctx) and returns
@@ -97,6 +103,12 @@ type Server struct {
// leaving it unset), just not deduped. Cleared alongside profAgents on profile edits.
provCacheMu sync.Mutex
provByProfile map[int64]*provEntry
// llmHealth is the process-wide circuit-breaker state for LLM failover (轮询).
// It deliberately lives OUTSIDE the provider caches: rebuilding the chain
// (saving an unrelated profile, flipping a setting) must not erase what we
// learned about which backends are out of credit / rate-limited.
llmHealth *llmpool.Registry
}
// profBundle is a planner/worker pair built from one LLM profile.
@@ -131,7 +143,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{},
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}}
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg)}
// per-task LLM: a task pinned to a specific profile runs on that profile's
// dedicated planner/worker; unpinned tasks fall back to the global active pair.
s.engine.SetAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) {
@@ -291,10 +303,15 @@ func (s *Server) saveLLMConfig(cfg agent.Config) {
format = "openai"
}
var id int64
// agent.Config carries no failover fields, so carry the stored ones forward —
// otherwise this legacy endpoint would silently reset the profile's priority
// and pool_exclude to zero values on every save.
var priority int
var poolExclude bool
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
for _, p := range profs {
if p.Name == "default" {
id = p.ID
id, priority, poolExclude = p.ID, p.Priority, p.PoolExclude
break
}
}
@@ -303,6 +320,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) {
ID: id, Name: "default", Format: format, Model: cfg.Model, BaseURL: cfg.BaseURL, Proxy: cfg.Proxy,
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
ContextWindowK: cfg.ContextWindowK, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
Priority: priority, PoolExclude: poolExclude,
})
if err == nil {
_ = s.m.pg.SetActiveProfile(newID)
@@ -371,6 +389,12 @@ func (s *Server) applyLLM(cfg agent.Config) error {
profName := s.llmProf
s.cfgMu.Unlock()
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
// LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。
// 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile,
// 默认仍然独占该配置(见 poolForBinding)。关闭或无备选时返回原 provider,行为不变。
if act, err := s.m.pg.ActiveProfile(); err == nil && act != nil {
prov = s.poolForActive(act.ID, prov, cfg)
}
pl, wk := s.buildPlannerWorker(nil, prov, cfg)
s.engine.UseLLM(pl, wk)
@@ -391,6 +415,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
s.chatAgent.SetGuard(s.chatGuard())
s.llmCfg = cfg
s.llmProv = prov
s.llmOn = true
s.cfgMu.Unlock()
@@ -468,10 +493,14 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool)
// providerForAgent returns the provider+cfg one agent should run on: its bound/pinned
// profile if that resolves, else the passed global fallback (gProv/gCfg).
// A bound/pinned profile is EXCLUSIVE by default: it does not fail over, so an
// agent deliberately put on a specific model never silently runs on another one.
// Turning on llm_pool_bind_fallback relaxes that — poolForBinding then appends the
// failover chain behind it.
func (s *Server) providerForAgent(agentKey string, pinID *int64, gProv llm.Provider, gCfg agent.Config) (llm.Provider, agent.Config) {
if eff := s.effectiveProfileForAgent(agentKey, pinID); eff != nil {
if prov, cfg, ok := s.providerForProfile(*eff); ok {
return prov, cfg
return s.poolForBinding(*eff, prov, cfg), cfg
}
}
return gProv, gCfg
@@ -492,7 +521,9 @@ func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) {
if !ok {
return nil, nil
}
pl, wk := s.buildPlannerWorker(&id, prov, cfg)
// The pin is the fallback for agents without their own binding — same
// exclusive-by-default rule, so route it through poolForBinding too.
pl, wk := s.buildPlannerWorker(&id, s.poolForBinding(id, prov, cfg), cfg)
s.profMu.Lock()
if ex := s.profAgents[id]; ex != nil { // lost the race → keep the winner
pl, wk = ex.pl, ex.wk
@@ -517,7 +548,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
return nil
}
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, cfg.CompactionWindow())
ca := agent.NewChatAgent(s.poolForBinding(id, prov, cfg), cfg.Model, s.m.dir, tx, cfg.CompactionWindow())
ca.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
ca.SetWebSearch(s.m.WebSearchOpts())
ca.SetGuard(s.chatGuard())
@@ -726,6 +757,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/llm/profiles", s.pgSaveProfile)
mux.HandleFunc("DELETE /api/llm/profiles/{id}", s.pgDeleteProfile)
mux.HandleFunc("POST /api/llm/profiles/active", s.pgActivateProfile)
mux.HandleFunc("GET /api/llm/pool", s.pgLLMPoolStatus)
mux.HandleFunc("POST /api/llm/pool/reset", s.pgLLMPoolReset)
mux.HandleFunc("POST /api/llm/models", s.pgListModels)
// 拦截规则管理
@@ -2191,6 +2224,10 @@ func (s *Server) settingsPayload() map[string]any {
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
// LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
"llm_pool_enabled": s.m.LLMPoolEnabled(),
"llm_pool_bind_fallback": s.m.LLMPoolBindFallback(),
}
}
@@ -2227,6 +2264,10 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
ConcurrencyEnabled *bool `json:"task_concurrency_enabled"`
ConcurrencyLimit *int `json:"task_concurrency_limit"`
// LLM 轮询(故障转移)开关 + 「绑定配置失败也兜底回轮询链」开关。两者都需要
// 重建 provider 链才生效,走下面的 changed → applyLLM 路径。
LLMPoolEnabled *bool `json:"llm_pool_enabled"`
LLMPoolBindFallback *bool `json:"llm_pool_bind_fallback"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -2272,6 +2313,25 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
}
}
changed := false
if req.LLMPoolEnabled != nil {
if err := s.m.SetLLMPoolEnabled(*req.LLMPoolEnabled); err != nil {
writeErr(w, 500, err.Error())
return
}
changed = true // the provider chain itself changes shape → rebuild
}
if req.LLMPoolBindFallback != nil {
if err := s.m.SetLLMPoolBindFallback(*req.LLMPoolBindFallback); err != nil {
writeErr(w, 500, err.Error())
return
}
changed = true
}
if req.LLMPoolEnabled != nil || req.LLMPoolBindFallback != nil {
// Pinned tasks' planner/worker and per-profile chat agents hold providers
// built under the OLD switch state — drop them so they pick up the new one.
s.invalidateProfileAgents()
}
if req.TrafficCapture != nil {
if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil {
writeErr(w, 500, err.Error())
+40 -4
View File
@@ -1609,10 +1609,10 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
}
// Editing a profile rebuilds any task pinned to it on its next round.
s.invalidateProfileAgents()
// If we just edited the active profile, hot-apply so changes take effect now.
if act, _ := pg.ActiveProfile(); act != nil && act.ID == id {
s.reapplyActiveProfile()
}
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any
// profile's key/model/priority/pool_exclude edit reshapes the chain, so the
// engine's provider has to be rebuilt either way.
s.reapplyActiveProfile()
writeJSON(w, 200, map[string]any{"id": id})
}
@@ -1627,6 +1627,8 @@ func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) {
return
}
s.invalidateProfileAgents() // drop cached agents for the removed profile
s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away)
s.reapplyActiveProfile() // the deleted profile may have been in the failover chain
writeJSON(w, 200, map[string]any{"deleted": id})
}
@@ -1651,6 +1653,40 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgLLMPoolStatus reports the failover ("轮询") switches, the resolved chain order
// and every profile's circuit-breaker state — what the LLM page renders as the
// "轮询顺序" strip and the per-card health badges.
func (s *Server) pgLLMPoolStatus(w http.ResponseWriter, r *http.Request) {
if s.pg(w) == nil {
return
}
writeJSON(w, 200, s.llmPoolStatus())
}
// pgLLMPoolReset clears a tripped profile's circuit breaker so the next call
// tries it again immediately ("立即恢复"). id=0 clears every profile.
func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var body struct {
ID int64 `json:"id"` // 0 / omitted = all
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if body.ID > 0 {
s.llmHealth.Reset(body.ID)
} else {
for id := range s.llmHealth.Snapshot() {
s.llmHealth.Reset(id)
}
}
writeJSON(w, 200, s.llmPoolStatus())
}
// pgListModels fetches available models from the provider's API endpoint.
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on