Files
Cyrene_v2/internal/services/gateway/admin_write_llm_channels.go
T
AskaEth 47e56ca13c feat(admin): 面板暴露 contextWindow(可见可改)+ DocumentRef 语义注释同步
- 面板读写面与 maxTokens 逐处对齐:读 DTO 增 contextWindow(omitempty)、写请求 ContextWindow *int(缺失=保留、显式 0=未声明同义、负数 400)、回写 models.json;负数红线仍由 runtimeconfig.Group.Validate 唯一裁决,网关守卫只做「更早失败 + 面板可读字段名」。前端:类型 + 表单字段(提交前 /^\d+$/ 校验)+ 分组行角标 + Notice + 目录候选用早已解析却没人用的 contextLimit 预填。文案写明「重启后生效」(推导只在进程启动时算一次)。

- 顺手修既有红线违反:ProviderFields 87→76(抽 ApiKeyAndBudgetField)、LlmProviderForm 113→74(buildRequest 提为模块级纯函数)——本次碰过的文件里已无 >80 的函数,行为/文案逐字未变。

- DocumentRef 的 chars/truncated 注释从「抽取侧」改为「本回合注入侧」(全文长度只在 document-read 的 totalChars、extractTruncated 专指撞 8 MiB 安全上限);机械校验为空 ⇒ 代码零改动。

- 主会话独立验收:隔离 worktree Go 侧 build/vet + gateway/history 两包绿;主树 npx tsc --noEmit exit 0、npm run build 成功并 copy 到 internal/webui/dist;注释机械校验为空;变异由子代理复现(写路径不认该键 ⇒ 3 红;去掉网关负数守卫 ⇒ 400 变 500)。
2026-10-04 18:11:43 +08:00

447 lines
17 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"
)
// llmReasoningRequest 是请求体里的推理开关形状块。整块缺省 = 保留已存形状;
// 整块给出(哪怕只有 `{}`)= 整块替换,因此面板能把形状改回「未声明」。
type llmReasoningRequest struct {
Kind *string `json:"kind"`
}
// llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新
// provider 分组。每个字段都是指针(映射类型用 nil 判定),使 handler 能区分
// 「缺失」(保留已存值)与「显式零值」(覆盖):来自面板的局部
// 编辑绝不清除它未触碰的字段。Name 是客户端侧
// 显示标签(不持久化 —— models.json 结构含 id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens/contextWindow/reasoning/
// personaModels/personaProviders)。
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"`
// ContextWindow 是该分组声明的模型上下文窗口(token)。缺失则保留已存窗口
// (新分组 0 = 未声明);显式 0 与缺失同义(都按回落窗口推导);负值被拒绝。
ContextWindow *int `json:"contextWindow"`
// Reasoning 缺失 = 保留已存形状;给出(含 `{}`)= 用其中的 kind 覆盖
// (kind 缺失/空串 = 回到「未声明」,等同 auto)。
Reasoning *llmReasoningRequest `json:"reasoning"`
// PersonaModels / PersonaProviders 是 persona 路由表,缺失 = 保留已存表,
// 给出(含 `{}`)= 整表替换。用整表替换而非合并:否则面板无法删除一条
// 已存路由,只能覆盖它的值。
PersonaModels map[string]string `json:"personaModels"`
PersonaProviders map[string]string `json:"personaProviders"`
}
// 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
}
// 窗口没有负数语义(与 runtimeconfig.Group.Validate 同一口径:只拒绝负数,
// 0 与缺失都是「未声明」)。这里提前报错是为了给出面板能直接显示的字段名,
// 规则本身不在网关另立一套。
if req.ContextWindow != nil && *req.ContextWindow < 0 {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "contextWindow 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 := adminLlmProviderDTO(group, providerDisplayName(group), group.ID == s.llm.Group().ID)
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"provider": saved,
"persisted": true,
"path": s.llm.Path(),
})
}
// mergeLlmProvider 把局部 provider 补丁施加到已存分组之上:
// 请求中出现的字段胜出,缺失字段保留已存值(新分组
// 则用其默认值)。它返回经过校验的分组——校验走配置层同一入口
// (runtimeconfig.Group.Validate),因此非法的 reasoning.kind /
// 非法的 personaProviders 协议名都在这里以错误浮出(写端点回 400),
// 绝不静默丢弃或悄悄改成别的值。
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 req.ContextWindow != nil {
g.ContextWindow = *req.ContextWindow
}
if req.Reasoning != nil {
g.Reasoning = runtimeconfig.ReasoningOptions{Kind: reasoningKindOf(req.Reasoning)}
}
if req.PersonaModels != nil {
models, err := normalizePersonaMap("personaModels", req.PersonaModels)
if err != nil {
return runtimeconfig.Group{}, err
}
g.PersonaModels = models
}
if req.PersonaProviders != nil {
providers, err := normalizePersonaMap("personaProviders", req.PersonaProviders)
if err != nil {
return runtimeconfig.Group{}, err
}
g.PersonaProviders = providers
}
if g.BaseURL == "" || g.Model == "" {
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
}
if err := g.Validate(); err != nil {
return runtimeconfig.Group{}, err
}
return g, nil
}
// reasoningKindOf 取出请求里的形状名:kind 缺失/空串 = 未声明形状(等同 auto,
// 等同不写这把键),因此面板能显式「改回未声明」而不是被局部编辑语义锁死在
// 已存形状上。
func reasoningKindOf(req *llmReasoningRequest) string {
if req == nil || req.Kind == nil {
return ""
}
return strings.TrimSpace(*req.Kind)
}
// 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,记忆只读不写)"
}
}