Files
Cyrene_v2/internal/services/gateway/admin_write_llm_reset.go
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .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 生效。
2026-10-04 21:33:11 +08:00

123 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}