- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。
- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。
- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。
- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。
- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。
- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。
- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。
- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
123 lines
5.1 KiB
Go
123 lines
5.1 KiB
Go
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
|
||
}
|