package model import ( "context" "errors" "io" "log/slog" "sync/atomic" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // ModelRouter 是 agent(与 loop)依赖的路由契约。*Router // 实现它;RuntimeLLM 也实现它,因此一个可热切换的提供商 // 可以同时充当适配器与路由器,无需另存一份 router 引用 // 去保持同步。 type ModelRouter interface { RouteRequest(req Request) (model string, needsVision bool) } // runtimeChainSnapshot 是不可变、自包含的 LLM 栈快照。它 // 被原子存储与换入:已经捕获某个快照的请求 // 整次调用都用那条确切的链+路由器,所以一次重载绝不会 // 打断进行中的请求。 type runtimeChainSnapshot struct { chain *Chain router *Router group runtimeconfig.Group groups []runtimeconfig.Group // providerChains 为生效组的 personaProviders 声明的每个 // *其它* 协议各存一条链(裁决 03.4.1 的 per-persona provider:跨协议换 // adapter)。提供商绑定解析到这些键之一的 persona 由 // 那条链服务;其余所有请求仍走上面的默认链。 providerChains map[string]*Chain } // RuntimeLLM 是运行时的可热切换 LLM 提供商栈。它 // 实现 Adapter(委托给当前链)与 ModelRouter // (委托给当前路由器)。面板保存会重载 models.json、重建 // 一份新的链+路由器并原子换入:新调用使用新栈, // 而进行中的调用在旧栈上跑完。 type RuntimeLLM struct { path string env config.LLMConfig log *slog.Logger current atomic.Value // 类型:*runtimeChainSnapshot } // NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退 // 到 env。它会加载初始快照,因此运行时立即可用。 // log 可为 nil(此时使用丢弃式日志器)。 func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM { if log == nil { log = slog.New(slog.NewTextHandler(io.Discard, nil)) } m := &RuntimeLLM{path: path, env: env, log: logger.WithSource(log, "model")} m.loadInitial() return m } // Path 返回本管理器读取并重载的 models.json 路径。 func (m *RuntimeLLM) Path() string { return m.path } // Groups 返回当前已加载组的一份拷贝;生效组(新请求 // 默认使用的那个)是下标 0。 func (m *RuntimeLLM) Groups() []runtimeconfig.Group { src := m.load().groups out := make([]runtimeconfig.Group, len(src)) copy(out, src) return out } // Group 返回生效组(下标 0)。 func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group } // Current 返回当前链与路由器。返回的链对进行中的 // 调用仍然有效;新调用会拿到最新的换入结果。 func (m *RuntimeLLM) Current() (*Chain, *Router) { snap := m.load() return snap.chain, snap.router } // Reload 重新读取 models.json(回退到 env)并原子重建 // 在用的链+路由器。进行中的调用保持原快照。 func (m *RuntimeLLM) Reload() error { groups, warn := runtimeconfig.LoadModels(m.path, m.env) if warn != nil { m.log.Warn("models.json reload fell back to env", "error", warn) } return m.swap(groups) } // Provider 返回生效组的提供商协议。 func (m *RuntimeLLM) Provider() string { return m.load().group.Provider } // Model 返回生效组的主模型名。 func (m *RuntimeLLM) Model() string { return m.load().group.Model } // Name 实现 Adapter,返回生效组 id。 func (m *RuntimeLLM) Name() string { return m.load().group.ID } // Chat 实现 Adapter,委托给请求的 persona 所解析到的 // 链。在重载完成前开始的请求,会在它已捕获的快照上 // 跑完。未配置模型(链为 nil)时返回 ErrNoModel,而不是 // panic。 func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) { chain := m.chainFor(req.PersonaID) if chain == nil { return Response{}, ErrNoModel } return chain.Chat(ctx, req, opts) } // ChatStream 实现 Adapter。 func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { chain := m.chainFor(req.PersonaID) if chain == nil { return Response{}, ErrNoModel } return chain.ChatStream(ctx, req, onEvent) } // chainFor 解出服务该 persona 的链(裁决 03.4.1 的 // per-persona provider)。它消费 Router.RouteProviderForPersona —— // 路由器已为该 persona 做出的提供商决策 —— 且只在 // 二者同时成立时才切换: // // - 该 persona 有自己的模型绑定(只写 personaProviders 一项 // 会把组的默认模型名发到另一厂商的端点), // - 且该提供商存在一条链(声明了协议但 models.json 里没有 // 对应组的情况会在构建快照时上报, // 该 persona 随后保留默认链,而不是让回合失败)。 // // 其余情形 —— 无 persona、无绑定、与生效组同协议 —— // 返回默认链,因此没有按 persona 提供商的部署 // 行为与之前完全一致。 func (m *RuntimeLLM) chainFor(persona string) *Chain { snap := m.load() if p := trimKey(persona); p != "" && snap.router != nil { if _, _, ok := snap.group.PersonaRoute(p); ok { if c := snap.providerChains[snap.router.RouteProviderForPersona(p)]; c != nil { return c } } } return snap.chain } // RouteRequest 实现 ModelRouter,委托给当前路由器。 func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) { r := m.load().router if r == nil { return "", false } return r.RouteRequest(req) } // RouteRequestForPersona 实现 PersonaRouter(裁决 03.4.1):loop 以 // 本回合作答所依据的 persona 来路由,生效 models.json 组里的 // 按 persona 绑定经由这里到达请求。 func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) { r := m.load().router if r == nil { return "", false } return r.RouteRequestForPersona(personaID, req) } // loadInitial 构建首个快照;构建失败会留下 nil 链, // 使调用报告清晰的「无模型」错误而不是 panic。 func (m *RuntimeLLM) loadInitial() { groups, warn := runtimeconfig.LoadModels(m.path, m.env) if warn != nil { m.log.Warn("models.json load fell back to env", "error", warn) } _ = m.swap(groups) } // swap 为 groups 重建快照并原子存储。空切片会回 // 退到 env 合成的组,因此始终至少有一个组。 func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error { if len(groups) == 0 { groups = runtimeconfig.GroupFromEnv(m.env) } snap, err := buildSnapshot(groups, m.env, m.log) if err != nil { return err } m.current.Store(snap) return nil } // load 返回当前快照;管理器从未初始化时返回一个空 // (nil 链)快照。 func (m *RuntimeLLM) load() *runtimeChainSnapshot { if v := m.current.Load(); v != nil { return v.(*runtimeChainSnapshot) } return &runtimeChainSnapshot{} } // buildSnapshot 把生效组(下标 0)变成链+路由器。没有 // base URL/模型的组会产出 nil 链(未配置模型),由适配器 // 方法表现为 ErrNoModel。 // // env 本地级会被复制到组的扁平配置上,因为 models.json // 的组不携带它:没有这次复制,第三级降级 // (裁决 03.4.3)只要 models.json 一存在就会静默消失。 // // 路由器以生效组的按 persona 绑定播种 // (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由 // 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了 // 那个决策(裁决 14.4.1/14.4.5)。 func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) { active := groups[0] cfg := active.ToLLMConfig() cfg.Local = env.Local chain, err := NewChatChainWithLog(cfg, log) if err != nil && !errors.Is(err, ErrNoModel) { return nil, err } if chain != nil { // 预算在链内部解出:生效组自己的 // maxTokens(models.json)优先,env.FallbackMaxTokens 只约束 // 确实有降级步骤的链的最后一步(design/04 §3.1)。 chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens) } router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...) return &runtimeChainSnapshot{ chain: chain, router: router, group: active, groups: groups, providerChains: personaProviderChains(groups, active, env, log), }, nil } // personaProviderChains 为生效组 personaProviders 声明的、且 // models.json 确实提供了对应组的每种协议各构建一条链 // (裁决 03.4.1 的 per-persona provider:跨协议换 adapter)。persona 自己的 // 模型名被钉为该链的主模型,因此即便调用方打上的 // persona 没带被路由的模型名,也仍能到达正确端点。 // // 与生效组同协议的 persona 不需要自己 // 的链:默认链本就说那个协议,而按 persona 的 // 模型名经路由器到达它。personaProviders 中协议 // 没有对应组的条目会被上报并跳过——半构建的链只会 // 失败,该 persona 改为保留默认链。 func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain { if len(active.PersonaProviders) == 0 { return nil } out := make(map[string]*Chain, len(active.PersonaProviders)) for persona := range active.PersonaProviders { model, provider, ok := active.PersonaRoute(persona) if !ok { warn(log, "personaProviders entry ignored: the persona binds no model of its own", "persona", persona, "field", "personaModels") continue } if provider == active.Provider { continue } if _, built := out[provider]; built { continue } g, found := groupForProvider(groups, provider) if !found { warn(log, "per-persona provider skipped: no models.json group speaks it", "persona", persona, "provider", provider) continue } chain := personaChain(g, model, env, log) if chain != nil { out[provider] = chain } } if len(out) == 0 { return nil } return out } // personaChain 构建并通过另一厂商的组服务某个 persona 的链,并 // 为其定预算:组的扁平配置,persona 自己的模型名作为 // 主模型,加上共享的本地最后一级。推理形状 // (reasoning.kind)随组的配置一起走,因此被路由到 // 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因, // 并在该组无法服务一条链时返回 nil。 func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain { cfg := g.ToLLMConfig() if cfg.Provider == ProviderAnthropic { cfg.AnthropicModel = model } else { cfg.Model = model } cfg.Local = env.Local chain, err := NewChatChainWithLog(cfg, log) if err != nil && !errors.Is(err, ErrNoModel) { warn(log, "per-persona chain skipped", "group", g.ID, "model", model, "error", err) return nil } if chain == nil { warn(log, "per-persona chain skipped: the provider group has no base url or model", "group", g.ID, "model", model) return nil } return chain.WithTokenBudget(g.MaxTokens, env.FallbackMaxTokens) } // groupForProvider 返回第一条说指定协议的组。models.json 里 // 组的顺序由运维者决定,所以「第一个匹配」是一条稳定、 // 可记录的规则,而不是一次静默挑选——重复协议会在 // 要紧的时候由构建器上报。 func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) { for _, g := range groups { if g.Provider == provider { return g, true } } return runtimeconfig.Group{}, false } // personaRouterOptions 把生效组的按 persona 模型/提供商 // 绑定转换成 router 选项(裁决 03.4.1)。它是一个辅助函数,好让 buildSnapshot // 留在 80 行函数预算之内。 func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption { opts := []RouterOption{WithDefaultProvider(cfg.Provider)} for persona, model := range active.PersonaModels { opts = append(opts, WithPersonaModel(persona, model)) } for persona, provider := range active.PersonaProviders { opts = append(opts, WithPersonaProvider(persona, provider)) } return opts }