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 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. Every field is a pointer so the handler can tell "absent" // (preserve the stored value) from "explicitly zero" (overwrite): a partial edit // from the panel must never wipe a field it did not touch. Name is a client-side // display label (not persisted — the models.json schema carries 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 is the group's own token budget. Absent preserves the stored // budget (0 = unset for a new group). Negative is rejected. MaxTokens *int `json:"maxTokens"` } // 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). A field the // client omits keeps its stored value, so a partial edit never resets a budget or // key. The API key is persisted to the gitignored file but never returned/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 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 } // 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(), 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 applies a partial provider patch over the stored groups: a // field present in the request wins, an absent field keeps the stored value (or // its default for a new group). It returns a validated group. 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 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,记忆只读不写)" } }