package gateway import ( "encoding/json" "errors" "net/http" "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" ) // 「重置为 env 默认」动作(切片 R1):把面板里**`.env` 确实声明过**的键覆盖回 env 值。 // 它与启动期的 materialize 补齐**方向相反、键表同源**(runtimeconfig 的 // groupFillSpecs / envLayer):materialize 是「面板缺该键才用 env 补」,本动作是 // 部署者**显式**要求「env 是基线,把这些键的面板值改回来」——例如面板把 // apiKey/baseUrl 改坏了、要一键回到 env 基线。 // llmResetFromEnvAction 是审计动作名(落 audit_logs 的 what 字段)。多词动作名沿用 // 本仓既有约定 camelCase(与 llm.addProvider / llm.test / persona.update 同风格)。 const llmResetFromEnvAction = "llm.resetFromEnv" // llmResetRequest 是 POST /api/v1/admin/llm/reset-from-env 的请求体。Confirm 必须 // **显式为 true**:本动作用 env 值覆盖面板对这些键的修改,属破坏性操作,因此缺护栏的 // 默认必须是「不执行」(键缺失与 false 都回 400,绝不静默照做)。 type llmResetRequest struct { Confirm bool `json:"confirm"` } // llmResetGroup 是响应里被改回的一组:**只回键名,绝不回值**。 type llmResetGroup struct { Group string `json:"group"` Keys []string `json:"keys"` } // llmResetSkip 是响应里未被改动的组与原因(中文文案,可直接给面板显示)。 type llmResetSkip struct { Group string `json:"group"` Reason string `json:"reason"` } // adminResetLlmFromEnv 把面板配置里 `.env` 声明过的键退回 env 值:组集合不增减、 // env 未声明的键原样保留、面板值已与 env 一致的键不动(没有任何键要改时**不写盘**)。 // 真的改了盘就热重载运行中的 LLM 栈,因此不需要重启(design/04 §3.1)。 // // 对照基准是**按既有 config 解析入口读到的 `.env` 默认层**(Deps.EnvPath),网关 // 自己绝不 fopen `.env`;读不到就以明确错误失败,一个字节都不改。 func (s *Server) adminResetLlmFromEnv(w http.ResponseWriter, r *http.Request) { var req llmResetRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if !req.Confirm { s.auditClaims(r, llmResetFromEnvAction, "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "重置为 env 默认会用 `.env` 声明的值覆盖面板对这些键的修改,请带 confirm:true 明确确认")) return } if s.llm == nil { s.auditClaims(r, llmResetFromEnvAction, "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入")) return } env, err := config.LoadLLMDefaults(s.envPath) if err != nil { s.auditClaims(r, llmResetFromEnvAction, "error") s.log.Error("llm reset: read env defaults", "envPath", s.envPath, "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "env_defaults_unreadable", "读取 `.env` 默认层失败,未改动面板配置:%s", err.Error())) return } res, err := runtimeconfig.ResetFromEnv(s.llm.Path(), env, s.log) if err != nil { s.auditClaims(r, llmResetFromEnvAction, "error") s.log.Error("llm reset from env", "path", s.llm.Path(), "error", err) if errors.Is(err, runtimeconfig.ErrPanelUnreadable) { writeError(w, apperr.Wrap(apperr.ErrInternal, "models_unreadable", "面板配置不可读,未改动任何文件:%s", err.Error())) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", "写回 models.json 失败,未生效:%s", err.Error())) return } if res.Written { if err := s.llm.Reload(); err != nil { s.auditClaims(r, llmResetFromEnvAction, "error") s.log.Error("llm reload", "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "已写回 models.json,但热重载失败")) return } } s.auditClaims(r, llmResetFromEnvAction, "ok") writeJSON(w, http.StatusOK, map[string]any{ "reset": resetGroupsDTO(res.Reset), "skipped": resetSkipsDTO(res.Skipped), "path": s.llm.Path(), }) } // resetGroupsDTO 把域结果映射成 wire 形状:数组恒存在(没改回任何键时是 `[]`, // 不是 `null`),键名原样透传、**值一律不出现在这条路径上**。 func resetGroupsDTO(groups []runtimeconfig.ResetGroup) []llmResetGroup { out := make([]llmResetGroup, 0, len(groups)) for _, g := range groups { keys := make([]string, 0, len(g.Keys)) keys = append(keys, g.Keys...) out = append(out, llmResetGroup{Group: g.GroupID, Keys: keys}) } return out } // resetSkipsDTO 同上:未被改动的组(含跳过原因)恒以数组形态下发。 func resetSkipsDTO(skips []runtimeconfig.ResetSkip) []llmResetSkip { out := make([]llmResetSkip, 0, len(skips)) for _, s := range skips { out = append(out, llmResetSkip{Group: s.GroupID, Reason: s.Reason}) } return out }