- runtimeconfig/models.go: models.json 承载 [{id,provider,baseUrl,apiKey,model,fallback?,multimodal?}] 多厂商独立组(首项=生效)
- LoadModels 优先文件/回退env(GroupFromEnv), SaveModels(0600+tmp原子写), Activate/Upsert/MaskedKey脱敏
- 面板 POST /admin/llm 落盘 models.json(取代temporary内存) + GET 列组(脱敏)
- runtime/model/runtime.go: RuntimeLLM(adapter+router) atomic快照可热替换, Reload 重建不中断进行中请求; per-role路由用接口
- wire 用 NewRuntimeLLM(adapter+router), 保留 a.modelAdapter(=runtime stack, 审查模型随热更新)
- .env 退兜底(.env.example注明); apiKey不打印/Config()脱敏/热更新不中断
- 全仓 go build/vet/test 绿(54包)+交叉编译+前端build绿(79模块); api-client v0.15已同步
- 遗留: vision ModelInfo 仍读静态env(后续纳入); 保存组=生效组(第一版语义)
320 lines
12 KiB
Go
320 lines
12 KiB
Go
package gateway
|
||
|
||
import (
|
||
"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"
|
||
)
|
||
|
||
// 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")
|
||
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.
|
||
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
|
||
if caps == nil {
|
||
return nil, false
|
||
}
|
||
for _, cap := range caps.List(capreg.KindChannel) {
|
||
if ch, ok := cap.Value.(sdk.Channel); ok && ch.Name() == name {
|
||
return ch, 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,记忆只读不写)"
|
||
}
|
||
}
|