Files
Cyrene_v2/internal/services/gateway/admin_write_llm_channels.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

395 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// LLM provider 与渠道生命周期的管理写操作(仅 owner,带审计)。LLM
// 测试会发起真实模型调用;provider「add」记录一条
// 进程生命周期内的条目。渠道开/关驱动真实适配器生命周期;身份
// 测试走结构化的 actor × 白名单规则,绝不是提示词文本。
const (
// ownerProfilePath 是为结构化身份测试加载的主人白名单。
// 它与 wire.go 用于主人识别的路径一致。
ownerProfilePath = "configs/users/owner.yaml"
// llmTestTimeout 限定单次 LLM 探测请求。
llmTestTimeout = 30 * time.Second
// llmProbePrompt 是用于验证模型能应答的最小提示词。
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新
// provider 分组。每个字段都是指针,使 handler 能区分「缺失」
// (保留已存值)与「显式零值」(覆盖):来自面板的局部
// 编辑绝不清除它未触碰的字段。Name 是客户端侧
// 显示标签(不持久化 —— models.json 结构含 id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens)。
type llmProviderRequest struct {
ID *string `json:"id"`
Name *string `json:"name"`
Provider *string `json:"provider"`
BaseURL *string `json:"baseUrl"`
APIKey *string `json:"apiKey"`
Model *string `json:"model"`
Multimodal *bool `json:"multimodal"`
Fallback *string `json:"fallback"`
// MaxTokens 是该分组自身的 token 预算。缺失则保留已存
// 预算(新分组 0 = 未设置)。负值被拒绝。
MaxTokens *int `json:"maxTokens"`
}
// adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write,
// 并将其提升为活动分组),再热重载运行中的 LLM 栈,使
// 新的 provider/model 无需重启即生效(design/04 §3.1)。客户端省略的
// 字段保持其已存值,因此局部编辑绝不复位预算或
// key。API key 被持久化进被 gitignore 的文件,但绝不返回/记日志。
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
var req llmProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.MaxTokens != nil && *req.MaxTokens < 0 {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
return
}
if s.llm == nil {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
return
}
group, err := mergeLlmProvider(s.llm.Groups(), req)
if err != nil {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
return
}
// 把已保存的分组提升到活动(下标 0)槽位,然后持久化并
// 热重载,使 Current() 立即反映新的 provider/model。
groups := runtimeconfig.Activate(s.llm.Groups(), group)
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
s.log.Error("llm save models", "path", s.llm.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", "failed to save models.json"))
return
}
if err := s.llm.Reload(); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
s.log.Error("llm reload", "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload"))
return
}
saved := AdminLlmProvider{
ID: group.ID,
Name: providerDisplayName(group),
Provider: group.Provider,
BaseURL: group.BaseURL,
Model: group.Model,
Multimodal: group.Multimodal,
Enabled: group.ID == s.llm.Group().ID,
Fallback: group.Fallback,
MaxTokens: group.MaxTokens,
}
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"provider": saved,
"persisted": true,
"path": s.llm.Path(),
})
}
// mergeLlmProvider 把局部 provider 补丁施加到已存分组之上:
// 请求中出现的字段胜出,缺失字段保留已存值(新分组
// 则用其默认值)。它返回经过校验的分组。
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
id := ""
if req.ID != nil {
id = strings.TrimSpace(*req.ID)
}
g, found := runtimeconfig.Find(groups, id)
if !found {
g = runtimeconfig.Group{}
}
g.ID = id
if g.ID == "" {
g.ID = runtimeconfig.NewID()
}
if req.Provider != nil {
g.Provider = strings.TrimSpace(*req.Provider)
}
if g.Provider == "" {
g.Provider = model.ProviderOpenAI
}
if g.Provider != model.ProviderOpenAI && g.Provider != model.ProviderAnthropic {
return runtimeconfig.Group{}, fmt.Errorf("provider must be openai or anthropic")
}
if req.BaseURL != nil {
g.BaseURL = strings.TrimSpace(*req.BaseURL)
}
if req.APIKey != nil {
g.APIKey = *req.APIKey
}
if req.Model != nil {
g.Model = strings.TrimSpace(*req.Model)
}
if req.Fallback != nil {
g.Fallback = strings.TrimSpace(*req.Fallback)
}
if req.Multimodal != nil {
g.Multimodal = *req.Multimodal
}
if req.MaxTokens != nil {
g.MaxTokens = *req.MaxTokens
}
if g.BaseURL == "" || g.Model == "" {
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
}
return g, nil
}
// llmTestRequest 是 POST /api/v1/admin/llm/test 的请求体。
type llmTestRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Prompt string `json:"prompt"`
}
// adminTestLlm 针对提供的 provider/model 端点执行一次真实补全
// 并报告结果 —— 真实的往返,而不是占位。
func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
var req llmTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
s.auditClaims(r, "llm.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "model and baseUrl are required"))
return
}
provider := req.Provider
if provider == "" {
provider = model.ProviderOpenAI
}
adapter, err := model.NewChatAdapter(config.LLMConfig{Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model})
if err != nil {
s.auditClaims(r, "llm.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
return
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
prompt = llmProbePrompt
}
ctx, cancel := context.WithTimeout(r.Context(), llmTestTimeout)
defer cancel()
resp, err := adapter.Chat(ctx, model.Request{
Messages: []model.Message{model.TextMessage(model.RoleUser, prompt)},
}, model.Options{Timeout: llmTestTimeout})
if err != nil {
s.auditClaims(r, "llm.test", "error")
s.log.Error("llm test", "provider", provider, "model", req.Model, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_test_failed", "model call failed: %s", err.Error()))
return
}
s.auditClaims(r, "llm.test", "ok")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
}
// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。
type channelActionRequest struct {
Name string `json:"name"`
}
// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.open")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.open", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Connect(r.Context()); err != nil {
s.auditClaims(r, "channel.open", "error")
// 单纯重连修不好的状态不得读作通用失败:
// weixin-iLink 机器人 token 被 iLink 拒绝(errcode=-14),因此
// 主人需要一次新的扫码登录。用该指引返回 409,
// 而不是假装渠道已打开(面板会把它标记为已连接)。
if errors.Is(err, weixin_ilink.ErrSessionRejected) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "session_rejected",
"该渠道会话已被 iLink 拒绝(errcode=-14):请用「扫码登录」重新绑定后再连接"))
return
}
// 尚无 token 的 weixin-iLink 既不能挂起,也不能读作凭据
// 错误:面板会把主人导向带外的扫码登录流程。
if errors.Is(err, weixin_ilink.ErrNeedsLogin) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接"))
return
}
s.log.Error("channel open", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
return
}
s.auditClaims(r, "channel.open", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
}
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
name, ok := s.channelName(r, w, "channel.close")
if !ok {
return
}
ch, found := findChannel(s.caps, name)
if !found {
s.auditClaims(r, "channel.close", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
return
}
if err := ch.Disconnect(r.Context()); err != nil {
s.auditClaims(r, "channel.close", "error")
s.log.Error("channel close", "name", name, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
return
}
s.auditClaims(r, "channel.close", "ok")
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
}
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
// 账号 id 加上对话形态,由结构化 actor 规则解析。
type channelTestRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
IsGroup bool `json:"isGroup"`
}
// adminChannelTest 运行结构化身份规则(actor 元数据 × 主人
// 白名单),绝不是提示词文本。它从磁盘加载主人白名单,
// 因此不匹配时退化为保守默认(member/stranger)。
func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
var req channelTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.AccountID) == "" {
s.auditClaims(r, "channel.identity-test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "accountId is required"))
return
}
msg := adapter.UnifiedMessage{
Platform: req.Platform,
ChannelID: req.AccountID,
SenderID: req.AccountID,
Sender: adapter.Actor{UserID: req.AccountID, IsGroup: req.IsGroup},
}
if req.IsGroup {
msg.ChannelType = adapter.ChannelGroup
} else {
msg.ChannelType = adapter.ChannelDirect
}
owner := loadOwnerProfile()
actor := session.ResolveActor(msg, owner)
env := session.EnvFor(actor)
s.auditClaims(r, "channel.identity-test", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"role": string(actor.Role),
"owner": actor.IsOwner(),
"isGroup": actor.IsGroupChat(),
"env": string(env),
"verdict": describeIdentity(actor, owner),
})
}
// channelName 为 open/close 路由解码 {name} 请求体,失败时写出 400。
// 它通过 ok 报告解码的唯一性。
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
var req channelActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return "", false
}
if strings.TrimSpace(req.Name) == "" {
s.auditClaims(r, op, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return "", false
}
return req.Name, true
}
// findChannel 返回名为 name 的已注册渠道适配器(若有)。精确的
// 适配器名("kook:conf0")胜出;非精确名的裸平台名("kook")
// 仍会解析到该平台首个已注册账号,因此面板/CLI 的
// "connect kook" 能抵达携带 token 的账号,即使它名为
// "kook:conf0"(空 token 的裸占位符从不注册)。
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
if caps == nil {
return nil, false
}
var first sdk.Channel
for _, cap := range caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
if ch.Name() == name {
return ch, true
}
if first == nil && channelBasePlatform(ch.Name()) == name {
first = ch
}
}
if first != nil {
return first, true
}
return nil, false
}
// loadOwnerProfile 从磁盘加载主人白名单,任何失败都返回
// nil,因此身份测试退化为保守的拒绝。
func loadOwnerProfile() *user.OwnerProfile {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
return nil
}
return p
}
// describeIdentity 为身份测试渲染结构化判定结果。
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
switch {
case a.IsGroupChat():
return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
case a.IsOwner():
return "匹配主人白名单 → owner(全量访问,记忆可读写)"
default:
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
}
}