package gateway import ( "errors" "context" "encoding/json" "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 and channel-lifecycle admin writes (owner-only, audited). The LLM // test performs a real model call; provider "add" records a process-lifetime // entry. Channel open/close drive the real adapter lifecycle; the identity test // is the structural actor × whitelist rule, never prompt text. const ( // ownerProfilePath is the owner whitelist loaded for the structural identity // test. It mirrors the path wire.go uses for owner recognition. ownerProfilePath = "configs/users/owner.yaml" // llmTestTimeout bounds a single LLM probe request. llmTestTimeout = 30 * time.Second // llmProbePrompt is the minimal prompt used to verify a model responds. llmProbePrompt = "Reply with the single word: OK" ) // llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a // provider group. Name is a client-side display label (not persisted — the // models.json schema carries id/provider/baseUrl/apiKey/model/fallback/multimodal). 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"` } // adminAddLlmProvider saves a provider group to models.json (read-modify-write, // promoting it to the active group) and hot-reloads the running LLM stack so the // new provider/model takes effect without a restart (design/04 §3.1). The API key // is persisted to the gitignored file but never returned or logged. 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 strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required")) return } provider := req.Provider if provider == "" { provider = model.ProviderOpenAI } if provider != model.ProviderOpenAI && provider != model.ProviderAnthropic { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider")) return } if s.llm == nil { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入")) return } id := req.ID if id == "" { id = runtimeconfig.NewID() } // Promote the saved group to the active (index 0) slot, then persist and // hot-reload so Current() reflects the new provider/model immediately. groups := runtimeconfig.Activate(s.llm.Groups(), runtimeconfig.Group{ ID: id, Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model, Fallback: req.Fallback, Multimodal: req.Multimodal, }) 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: id, Name: providerDisplayName(runtimeconfig.Group{Provider: provider}), Provider: provider, BaseURL: req.BaseURL, Model: req.Model, Multimodal: req.Multimodal, Enabled: id == s.llm.Group().ID, Fallback: req.Fallback, } s.auditClaims(r, "llm.addProvider", "ok") writeJSON(w, http.StatusCreated, map[string]any{ "provider": saved, "persisted": true, "path": s.llm.Path(), }) } // llmTestRequest is the POST /api/v1/admin/llm/test body. type llmTestRequest struct { Provider string `json:"provider"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` Model string `json:"model"` Prompt string `json:"prompt"` } // adminTestLlm performs a real completion against the supplied provider/model // endpoint and reports the result — a genuine round-trip, not a placeholder. 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 is the POST /api/v1/admin/channels/open|close body. type channelActionRequest struct { Name string `json:"name"` } // adminChannelOpen connects a registered channel adapter (real lifecycle call). 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 with no token yet must not hang nor read as a credential // error: the panel routes the owner to the out-of-band 扫码登录 flow. 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 disconnects a registered channel adapter (real lifecycle call). 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 is the POST /api/v1/admin/channels/test body: a channel // account id plus the conversation shape, resolved by the structural actor rule. type channelTestRequest struct { Platform string `json:"platform"` AccountID string `json:"accountId"` IsGroup bool `json:"isGroup"` } // adminChannelTest runs the structural identity rule (actor metadata × owner // whitelist), never prompt text. It loads the owner whitelist from disk so a // mismatch degrades to the conservative default (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 decodes the {name} body for open/close routes, writing a 400 on // failure. It reports uniqueness of the decode via 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 returns the registered channel adapter named name, if any. An exact // adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an // exact name still resolves to that platform's first registered account, so the // panel/CLI "connect kook" reaches the token-bearing account even when it is named // "kook:conf0" (the empty-token bare placeholder is never registered). 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 loads the owner whitelist from disk, returning nil on any // failure so the identity test degrades to the conservative deny. func loadOwnerProfile() *user.OwnerProfile { p, err := user.LoadOwnerProfile(ownerProfilePath) if err != nil { return nil } return p } // describeIdentity renders the structural verdict for the identity test. func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string { switch { case a.IsGroupChat(): return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具" case a.IsOwner(): return "匹配主人白名单 → owner(全量访问,记忆可读写)" default: return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)" } }