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 生效。
This commit is contained in:
1 parent
367abf2c8e
commit
c1fa1e9b9a
19 files changed
+1434
-11
No files matched your search
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// LoadLLMDefaults 按既有解析入口读取 `.env` 默认层的 LLM 块。它是面板
|
||||
// 「重置为 env 默认」动作(切片 R1,见 runtimeconfig.ResetFromEnv)的**对照基准**:
|
||||
// 那个动作要知道「`.env` 到底声明了哪些键」,而网关自己绝不 fopen `.env`。
|
||||
//
|
||||
// 它复用 parseDotEnv 与同一份 `env:"…"` 标签解码器,因此优先级与 Load 逐字一致:
|
||||
// 进程环境变量 > `.env` > 标签默认值(生产走 systemd/env_file 时不配 `.env`,
|
||||
// 结果就是进程环境里那一份)。envPath 为空表示不看 `.env`(与 Load 的约定一致)。
|
||||
//
|
||||
// 为什么只解 LLM 子树、而不是整份 Load:整份 Load 会在缺 JWT_SECRET 这类必填项时
|
||||
// 失败,而本读取只需要 LLM 块的声明;窗口负数在 Load 阶段就会报错(窗口没有负数
|
||||
// 语义),这里保持同一口径,不另立一套判定。
|
||||
func LoadLLMDefaults(envPath string) (LLMConfig, error) {
|
||||
fromEnv := map[string]string{}
|
||||
if envPath != "" {
|
||||
m, err := parseDotEnv(envPath)
|
||||
if err != nil {
|
||||
return LLMConfig{}, fmt.Errorf("config: read %s: %w", envPath, err)
|
||||
}
|
||||
fromEnv = m
|
||||
}
|
||||
var llm LLMConfig
|
||||
if err := decodeValue(reflect.ValueOf(&llm), fromEnv); err != nil {
|
||||
return LLMConfig{}, err
|
||||
}
|
||||
if llm.ContextWindow < 0 {
|
||||
return LLMConfig{}, fmt.Errorf("config: LLM_CONTEXT_WINDOW 不能为负(%d):0 = 未声明(按回落窗口推导)",
|
||||
llm.ContextWindow)
|
||||
}
|
||||
return llm, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住「重置为 env 默认」动作(切片 R1)的**对照基准读取**:`.env` 默认层必须
|
||||
// 按既有解析入口读(同一份 parseDotEnv + 同一份 `env:"…"` 标签解码器),优先级与
|
||||
// Load 逐字一致(进程环境 > `.env` > 标签默认),且读不出时明确报错——绝不回一份
|
||||
// 空快照冒充「env 什么都没声明」。
|
||||
//
|
||||
// 它刻意只解 LLM 子树:整份 Load 会在缺 JWT_SECRET 这类必填项时失败,而本读取不需要
|
||||
// 它们(第一个用例就把这条钉住)。
|
||||
|
||||
func TestLoadLLMDefaultsReadsEnvFileWithoutRequiredConfig(t *testing.T) {
|
||||
// 这份 `.env` 故意不含 JWT_SECRET 等必填项:本读取只解 LLM 子树,不该因此失败。
|
||||
path := writeEnvFile(t, strings.Join([]string{
|
||||
"# 注释与空行照旧被忽略",
|
||||
"LLM_BASE_URL=https://env.example/v1",
|
||||
"LLM_API_KEY=sk-env-plaintext",
|
||||
"LLM_MODEL=env-model",
|
||||
"LLM_CONTEXT_WINDOW=131072",
|
||||
"ANTHROPIC_MODEL=env-claude",
|
||||
"",
|
||||
}, "\n"))
|
||||
|
||||
llm, err := LoadLLMDefaults(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLLMDefaults: %v", err)
|
||||
}
|
||||
if llm.Provider != "openai" || llm.BaseURL != "https://env.example/v1" ||
|
||||
llm.APIKey != "sk-env-plaintext" || llm.Model != "env-model" || llm.ContextWindow != 131072 {
|
||||
t.Fatalf("llm = %+v, want `.env` 里逐字声明的扁平块(LLM_PROVIDER 未声明 ⇒ 标签默认 openai)", llm)
|
||||
}
|
||||
if llm.AnthropicModel != "env-claude" {
|
||||
t.Fatalf("AnthropicModel = %q, want env-claude(同一份解码器覆盖全部 LLM_*/ANTHROPIC_* 标签)", llm.AnthropicModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLLMDefaultsProcessEnvWinsOverEnvFile(t *testing.T) {
|
||||
path := writeEnvFile(t, "LLM_MODEL=from-file\n")
|
||||
t.Setenv("LLM_MODEL", "from-process")
|
||||
// 该键没被声明:必须仍是零值(空),不能被 `.env` 的其它行牵连。
|
||||
t.Setenv("LLM_BASE_URL", "")
|
||||
|
||||
llm, err := LoadLLMDefaults(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLLMDefaults: %v", err)
|
||||
}
|
||||
if llm.Model != "from-process" {
|
||||
t.Fatalf("Model = %q, want from-process(优先级必须与 Load 一致:进程环境 > .env)", llm.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLLMDefaultsUnreadableSourceFails(t *testing.T) {
|
||||
// 目录不是可读的 `.env`:读取必须明确报错,而不是静默回一份空声明。
|
||||
dir := t.TempDir()
|
||||
if _, err := LoadLLMDefaults(dir); err == nil {
|
||||
t.Fatal("对目录调用 LoadLLMDefaults 应报错, want 明确错误")
|
||||
}
|
||||
|
||||
// 不存在的文件按 `.env` 的开发期约定容忍(parseDotEnv 的既有语义):结果是
|
||||
// 「只按进程环境与标签默认」的一份快照,而不是错误。
|
||||
missing := filepath.Join(t.TempDir(), "absent.env")
|
||||
llm, err := LoadLLMDefaults(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("文件缺失应容忍(.env 只是开发期便利): %v", err)
|
||||
}
|
||||
if llm.Provider != "openai" || llm.BaseURL != "" {
|
||||
t.Fatalf("llm = %+v, want 标签默认 + 空串(没有 `.env` 可读)", llm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLLMDefaultsRejectsNegativeContextWindow(t *testing.T) {
|
||||
path := writeEnvFile(t, "LLM_CONTEXT_WINDOW=-1\n")
|
||||
if _, err := LoadLLMDefaults(path); err == nil {
|
||||
t.Fatal("负数窗口应报错(与 Load 同一口径:窗口没有负数语义)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件落地「重置为 env 默认」这个**显式**动作(切片 R1)。它与 materialize.go 的
|
||||
// 补齐方向相反,却共用**同一份** env 默认层投影(groupFillSpecs + envLayer):
|
||||
//
|
||||
// - Materialize(进程启动):面板缺该键 ⇒ 用 env 补上(面板是一等来源);
|
||||
// - ResetFromEnv(部署者显式要求):`.env` 声明过的键 ⇒ 覆盖回 env 值(env 是基线)。
|
||||
//
|
||||
// 共用同一张键表,因此「本动作能改回的键集合」与「Materialize 会补齐的键集合」
|
||||
// 结构性一致,不可能各说各话。组集合完全由面板决定:本动作不新增、不复活、不删除
|
||||
// 任何组(顺序也不动),只改键值。
|
||||
|
||||
// ErrPanelUnreadable 表示面板配置文件读不到或解析不了。它专供调用方把
|
||||
// 「配置不可读」与「写盘失败」分成两种处置(两种都不会改动文件)。
|
||||
var ErrPanelUnreadable = errors.New("runtimeconfig: panel config unreadable")
|
||||
|
||||
// 跳过原因:两种「这一组一个键都没改」的情形。它们是可直接给面板与审计看的
|
||||
// 中文文案,因此以常量承载,避免代码、测试与文档各写一份。
|
||||
const (
|
||||
// SkipReasonNoDefaults 是该组的 provider 族在 env 默认层里没有任何**有信息量**的
|
||||
// 键(见 isTautologicalEnvKey)——`.env` 没有为这一族声明默认值。
|
||||
SkipReasonNoDefaults = "`.env` 未声明该组的默认键(面板值原样保留)"
|
||||
// SkipReasonAlreadyDefault 是 env 声明过该组的键,但面板值已与 env 值一致。
|
||||
SkipReasonAlreadyDefault = "面板值已与 `.env` 默认一致,无需重置"
|
||||
)
|
||||
|
||||
// ResetGroup 是一次重置里**真的被改回**的分组:Keys 是 models.json 的 JSON 键名
|
||||
// (与结构体 tag 逐字一致),**不含任何值**——凭据类键的值绝不回显、绝不进日志。
|
||||
type ResetGroup struct {
|
||||
GroupID string
|
||||
Keys []string
|
||||
}
|
||||
|
||||
// ResetSkip 是一个未被改动的分组与原因(顺序即面板里的组顺序)。
|
||||
type ResetSkip struct {
|
||||
GroupID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ResetResult 是一次 ResetFromEnv 的可复核产物。
|
||||
type ResetResult struct {
|
||||
// Reset 是被改回的分组,只含**真的变了**的键(面板值已与 env 一致的不算改回)。
|
||||
Reset []ResetGroup
|
||||
// Skipped 是未被改动的分组与原因。
|
||||
Skipped []ResetSkip
|
||||
// Written 为 true 表示本次真的改动了面板文件;无改动时一个字节都不动(幂等)。
|
||||
Written bool
|
||||
}
|
||||
|
||||
// ResetFromEnv 把面板组里「`.env` 默认层确实声明过」的键覆盖回 env 值。
|
||||
//
|
||||
// 语义(与 Materialize 的补齐语义互为对照):
|
||||
// - 只动 `.env` 声明了非空值的键;env 未声明的键**一律保留面板值**;
|
||||
// - 组集合**不增减**(不复活被删的组、不删新增的组,顺序也不动);
|
||||
// - 面板值已与 env 值一致的键不算「改回」,不出现在 Reset 里;
|
||||
// - 没有任何键需要改时**不写盘**(幂等)。
|
||||
//
|
||||
// 失败一律返回错误且**一个字节都不落盘**:面板文件不可读/不可解析(ErrPanelUnreadable)
|
||||
// 或写盘失败(SaveModels 自身的错误),调用方据此回显式错误,绝不假装成功。
|
||||
// log 可为 nil(slog 对 nil logger 安全)。
|
||||
func ResetFromEnv(path string, env config.LLMConfig, log *slog.Logger) (ResetResult, error) {
|
||||
log = logger.WithSource(log, "config/runtimeconfig")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ResetResult{}, fmt.Errorf("%w: 面板配置文件不存在(%s),没有可重置的面板值: %w",
|
||||
ErrPanelUnreadable, path, err)
|
||||
}
|
||||
return ResetResult{}, fmt.Errorf("%w: 读取 %s: %w", ErrPanelUnreadable, path, err)
|
||||
}
|
||||
panel, err := decodePanel(raw)
|
||||
if err != nil {
|
||||
return ResetResult{}, fmt.Errorf("%w: 解析 %s: %w", ErrPanelUnreadable, path, err)
|
||||
}
|
||||
layer := newEnvLayer(env)
|
||||
groups := make([]Group, len(panel.groups))
|
||||
var res ResetResult
|
||||
for i, g := range panel.groups {
|
||||
groups[i] = g
|
||||
changed, declared := resetGroup(&groups[i], layer)
|
||||
if len(changed) == 0 {
|
||||
res.Skipped = append(res.Skipped, ResetSkip{GroupID: reportGroupID(g.ID), Reason: skipReason(declared)})
|
||||
continue
|
||||
}
|
||||
res.Reset = append(res.Reset, ResetGroup{GroupID: reportGroupID(g.ID), Keys: changed})
|
||||
}
|
||||
if len(res.Reset) == 0 {
|
||||
log.Debug("面板配置无需重置:没有与 `.env` 默认不同的键", "path", path, "source", envLayerSource)
|
||||
return res, nil
|
||||
}
|
||||
// SaveModels 先逐组校验再原子写(临时文件 + rename),因此校验或写盘失败时
|
||||
// 原文件保持原样——「重置失败」绝不留下半写状态。
|
||||
if err := SaveModels(path, groups); err != nil {
|
||||
return res, fmt.Errorf("runtimeconfig: 重置 %s: %w", path, err)
|
||||
}
|
||||
res.Written = true
|
||||
logReset(log, path, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// resetGroup 把 env 为该组声明的键覆盖回 env 值,返回**真的被改回**的键与
|
||||
// 「env 为该组声明了几个**有信息量**的键」(见 isTautologicalEnvKey)。键顺序即
|
||||
// groupFillSpecs 的顺序(与被补的键同序)。
|
||||
func resetGroup(g *Group, layer envLayer) (changed []string, declared int) {
|
||||
defaults := layer.defaultsFor(*g)
|
||||
for _, spec := range groupFillSpecs {
|
||||
value := defaults[spec.key]
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if !isTautologicalEnvKey(spec.key) {
|
||||
declared++
|
||||
}
|
||||
if spec.get(*g) == value {
|
||||
continue
|
||||
}
|
||||
spec.set(g, value)
|
||||
changed = append(changed, spec.key)
|
||||
}
|
||||
return changed, declared
|
||||
}
|
||||
|
||||
// isTautologicalEnvKey 报告该键在 env 默认层里是不是**同义反复**:`provider` 的值由
|
||||
// 「按组自己的 provider 选族」合成,因此恒等于组里已有的值,**不可能产生一次「改回」**。
|
||||
// 它仍留在比较表内(与 materialize 的补齐集合逐字一致,`provider` 确实可能被补),但
|
||||
// **不计入**「`.env` 到底为该组声明了什么」的判定——否则「`.env` 完全没声明这一族」会被
|
||||
// 它伪装成「面板值已一致」,面板上那条跳过原因就会骗人。
|
||||
func isTautologicalEnvKey(key string) bool { return key == "provider" }
|
||||
|
||||
// skipReason 把「env 为这一组声明了几个键」翻成给面板看的原因。
|
||||
func skipReason(declared int) string {
|
||||
if declared == 0 {
|
||||
return SkipReasonNoDefaults
|
||||
}
|
||||
return SkipReasonAlreadyDefault
|
||||
}
|
||||
|
||||
// reportGroupID 是报告用的组标签:组 id 为空(手写配置,本就不合法)时用占位符,
|
||||
// 与 fill 日志的 tagKeys 同一约定,避免出现空字符串组名。
|
||||
func reportGroupID(id string) string {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return "(no-id)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// logReset 汇报一次重置:只打组 id 与键名,**绝不打任何值**(apiKey 等凭据不进日志)。
|
||||
func logReset(log *slog.Logger, path string, res ResetResult) {
|
||||
groups := make([]string, 0, len(res.Reset))
|
||||
for _, g := range res.Reset {
|
||||
groups = append(groups, g.GroupID+"."+strings.Join(g.Keys, ","))
|
||||
}
|
||||
log.Info("面板配置已按 .env 默认层重置(只改键值,组集合不变)", "path", path, "source", envLayerSource,
|
||||
"reset", groups, "resetCount", len(res.Reset), "skippedCount", len(res.Skipped))
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件钉住「重置为 env 默认」这个**显式**动作的语义(切片 R1,实现见 reset.go):
|
||||
// 只改 `.env` 声明过的键、env 未声明的键原样保留、组集合与顺序不增减、面板值已与
|
||||
// env 一致时不写盘(幂等),且失败一个字节都不落盘 —— 与 materialize 的补齐方向相反、
|
||||
// 键表同源。
|
||||
|
||||
// resetPanelFile 写一份含三组的面板配置:openai 组(面板值全都与 env 不同)、
|
||||
// anthropic 组(env 侧另有一块 anthropic 声明)、以及 env 里完全没有对应声明的
|
||||
// 第三组(它的面板值只可能被保留)。返回文件路径。
|
||||
func resetPanelFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeModelsFile(t, `[
|
||||
{"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel-key","model":"panel-model",
|
||||
"fallback":"panel-fallback","multimodal":true,"maxTokens":4096,"contextWindow":4096,
|
||||
"reasoning":{"kind":"vllm"},"personaModels":{"cyrene":"panel-model"}},
|
||||
{"id":"panel-anthropic","provider":"anthropic","baseUrl":"https://panel-anthropic.example","apiKey":"sk-panel-ant","model":"panel-claude"},
|
||||
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"}
|
||||
]`)
|
||||
}
|
||||
|
||||
// TestResetFromEnvOnlyTouchesEnvDeclaredKeys 是核心语义:`.env` 声明过的键被改回
|
||||
// env 值,env 未声明的键(fallback / multimodal / maxTokens / reasoning /
|
||||
// personaModels)**原样保留**;`provider` 虽在 env 默认层里被声明,但面板值已与
|
||||
// env 一致,因此不算「被改回」。
|
||||
func TestResetFromEnvOnlyTouchesEnvDeclaredKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv()
|
||||
env.ContextWindow = 131072
|
||||
path := resetPanelFile(t)
|
||||
|
||||
res, err := ResetFromEnv(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("ResetFromEnv: %v", err)
|
||||
}
|
||||
if !res.Written {
|
||||
t.Fatal("Written = false, want true(有键被改回就该落盘)")
|
||||
}
|
||||
// 三组都在:openai 组被改回,anthropic 组被改回,extra 组也一样(同属 openai 族)。
|
||||
got := map[string][]string{}
|
||||
for _, g := range res.Reset {
|
||||
got[g.GroupID] = g.Keys
|
||||
}
|
||||
wantOpenAI := []string{"baseUrl", "apiKey", "model", "contextWindow"}
|
||||
if !equalStrings(got["panel-openai"], wantOpenAI) {
|
||||
t.Fatalf("panel-openai 改回的键 = %v, want %v(provider 已一致,不算改回)", got["panel-openai"], wantOpenAI)
|
||||
}
|
||||
wantAnthropic := []string{"baseUrl", "apiKey", "model", "contextWindow"}
|
||||
if !equalStrings(got["panel-anthropic"], wantAnthropic) {
|
||||
t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族;LLM_CONTEXT_WINDOW 对两族都播种)",
|
||||
got["panel-anthropic"], wantAnthropic)
|
||||
}
|
||||
if len(res.Skipped) != 0 {
|
||||
t.Fatalf("Skipped = %+v, want 空(每一组都有键被改回)", res.Skipped)
|
||||
}
|
||||
|
||||
// 磁盘上的真值:env 值覆盖,未声明的键原样保留。
|
||||
groups, warn := LoadModels(path, env)
|
||||
if warn != nil {
|
||||
t.Fatalf("LoadModels: warn = %v", warn)
|
||||
}
|
||||
g := mustFindGroup(t, groups, "panel-openai")
|
||||
if g.BaseURL != env.BaseURL || g.APIKey != env.APIKey || g.Model != env.Model || g.ContextWindow != env.ContextWindow {
|
||||
t.Fatalf("panel-openai = %+v, want env 的 baseUrl/apiKey/model/contextWindow", g)
|
||||
}
|
||||
if g.Fallback != "panel-fallback" || !g.Multimodal || g.MaxTokens != 4096 ||
|
||||
g.Reasoning.Kind != "vllm" || g.PersonaModels["cyrene"] != "panel-model" {
|
||||
t.Fatalf("panel-openai = %+v, want env 未声明的键原样保留", g)
|
||||
}
|
||||
ant := mustFindGroup(t, groups, "panel-anthropic")
|
||||
if ant.BaseURL != env.AnthropicBaseURL || ant.APIKey != env.AnthropicAPIKey || ant.Model != env.AnthropicModel {
|
||||
t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值", ant)
|
||||
}
|
||||
// 组集合与顺序不变(panel-extra 是面板新增的组,env 里没有它的 id)。
|
||||
if len(groups) != 3 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" || groups[2].ID != "panel-extra" {
|
||||
t.Fatalf("组集合/顺序 = %+v, want 原样的三组", groups)
|
||||
}
|
||||
}
|
||||
|
||||
// mustFindGroup 按 id 取组,取不到直接失败。
|
||||
func mustFindGroup(t *testing.T, groups []Group, id string) Group {
|
||||
t.Helper()
|
||||
g, ok := Find(groups, id)
|
||||
if !ok {
|
||||
t.Fatalf("组 %q 不在磁盘上: %+v", id, groups)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestResetFromEnvKeepsGroupsEnvWantsBack 钉住「组集合不因 env 而增减」:env 里
|
||||
// 有 anthropic 块,但面板把 anthropic 组删了 —— 重置**不复活**它;面板新增的组
|
||||
// 也**不被删除**(组数与顺序都不动)。
|
||||
func TestResetFromEnvKeepsGroupsEnvWantsBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv()
|
||||
path := writeModelsFile(t, `[
|
||||
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"}
|
||||
]`)
|
||||
before := readRaw(t, path)
|
||||
|
||||
res, err := ResetFromEnv(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("ResetFromEnv: %v", err)
|
||||
}
|
||||
if !res.Written {
|
||||
t.Fatal("Written = false, want true(openai 组确实被改回)")
|
||||
}
|
||||
groups, warn := LoadModels(path, env)
|
||||
if warn != nil {
|
||||
t.Fatalf("LoadModels: warn = %v", warn)
|
||||
}
|
||||
if len(groups) != 1 || groups[0].ID != "panel-extra" {
|
||||
t.Fatalf("组集合 = %+v, want 只有面板那一组(envelope 不复活被删的组)", groups)
|
||||
}
|
||||
if _, ok := Find(groups, ProviderAnthropic); ok {
|
||||
t.Fatal("env 声明的 anthropic 组被复活了, want 不新增组")
|
||||
}
|
||||
if readRaw(t, path) == before {
|
||||
t.Fatal("openai 组的键应被改回(文件该变)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFromEnvIdempotentNoWrite 钉住幂等:第二次调用时面板值已与 env 一致,
|
||||
// 因此**一个字节都不动**,并且报出「已一致」的跳过原因。
|
||||
func TestResetFromEnvIdempotentNoWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv()
|
||||
path := resetPanelFile(t)
|
||||
if _, err := ResetFromEnv(path, env, discardLog()); err != nil {
|
||||
t.Fatalf("第一次 ResetFromEnv: %v", err)
|
||||
}
|
||||
before := readRaw(t, path)
|
||||
|
||||
res, err := ResetFromEnv(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 ResetFromEnv: %v", err)
|
||||
}
|
||||
if res.Written {
|
||||
t.Fatal("Written = true, want false(无键可改时不写盘)")
|
||||
}
|
||||
if len(res.Reset) != 0 {
|
||||
t.Fatalf("Reset = %+v, want 空", res.Reset)
|
||||
}
|
||||
if len(res.Skipped) != 3 {
|
||||
t.Fatalf("Skipped = %+v, want 三组都报「已一致」", res.Skipped)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
if s.Reason != SkipReasonAlreadyDefault {
|
||||
t.Fatalf("跳过原因 = %q, want %q", s.Reason, SkipReasonAlreadyDefault)
|
||||
}
|
||||
}
|
||||
if readRaw(t, path) != before {
|
||||
t.Fatal("无键可改时文件被改动了, want 逐字节不变")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFromEnvUnreadablePanelFails 钉住失败路径:面板文件不存在/不可解析时
|
||||
// 明确报错(ErrPanelUnreadable),绝不假装成功、也绝不创建文件。
|
||||
func TestResetFromEnvUnreadablePanelFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv()
|
||||
|
||||
t.Run("文件不存在", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) {
|
||||
t.Fatalf("err = %v, want ErrPanelUnreadable", err)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatal("重置失败却创建了文件, want 什么都不做")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("内容不可解析", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeModelsFile(t, `{"not":"an array"}`)
|
||||
before := readRaw(t, path)
|
||||
if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) {
|
||||
t.Fatalf("err = %v, want ErrPanelUnreadable", err)
|
||||
}
|
||||
if readRaw(t, path) != before {
|
||||
t.Fatal("解析失败的文件被改写了, want 一个字节都不动")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResetFromEnvEnvDeclaresNothing 钉住「env 什么都没声明」这一侧:组的
|
||||
// baseUrl/model 等一个都不动,且**每一组**都报「env 未声明该组的默认键」——这一条
|
||||
// 之所以能成立,靠的是把 `provider`(同义反复,恒等于组里已有的值)排除在
|
||||
// 「env 声明了什么」的判定之外,否则面板上会显示成误导性的「面板值已一致」。
|
||||
func TestResetFromEnvEnvDeclaresNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := config.LLMConfig{Provider: ProviderOpenAI}
|
||||
path := resetPanelFile(t)
|
||||
before := readRaw(t, path)
|
||||
|
||||
res, err := ResetFromEnv(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("ResetFromEnv: %v", err)
|
||||
}
|
||||
if res.Written || len(res.Reset) != 0 {
|
||||
t.Fatalf("res = %+v, want 无改动(env 未声明任何键)", res)
|
||||
}
|
||||
if len(res.Skipped) != 3 {
|
||||
t.Fatalf("Skipped = %+v, want 三组都被跳过", res.Skipped)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
if s.Reason != SkipReasonNoDefaults {
|
||||
t.Fatalf("组 %s 的跳过原因 = %q, want %q", s.GroupID, s.Reason, SkipReasonNoDefaults)
|
||||
}
|
||||
}
|
||||
if readRaw(t, path) != before {
|
||||
t.Fatal("env 未声明任何键时文件被改动了, want 逐字节不变")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFromEnvSkipsFamilyWithoutDeclaration 钉住「按组自己的 provider 选族」的
|
||||
// 另一面:`.env` 只配了扁平 openai 块时,anthropic 组没有任何可改回的键,且原因必须
|
||||
// 是「`.env` 未声明」而不是「已一致」。
|
||||
func TestResetFromEnvSkipsFamilyWithoutDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := materializeEnv() // 含 anthropic 块
|
||||
env.AnthropicBaseURL, env.AnthropicAPIKey, env.AnthropicModel, env.AnthropicFallback = "", "", "", ""
|
||||
path := resetPanelFile(t)
|
||||
|
||||
res, err := ResetFromEnv(path, env, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("ResetFromEnv: %v", err)
|
||||
}
|
||||
var anthropic *ResetSkip
|
||||
for i := range res.Skipped {
|
||||
if res.Skipped[i].GroupID == "panel-anthropic" {
|
||||
anthropic = &res.Skipped[i]
|
||||
}
|
||||
}
|
||||
if anthropic == nil {
|
||||
t.Fatalf("anthropic 组不在 skipped 里: %+v", res.Skipped)
|
||||
}
|
||||
if anthropic.Reason != SkipReasonNoDefaults {
|
||||
t.Fatalf("anthropic 组跳过原因 = %q, want %q", anthropic.Reason, SkipReasonNoDefaults)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFromEnvNeverLogsCredentialValues 钉住凭据纪律:日志里只有组 id 与
|
||||
// 键名,env 与面板的 apiKey 值都不出现。
|
||||
func TestResetFromEnvNeverLogsCredentialValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
env := materializeEnv()
|
||||
path := resetPanelFile(t)
|
||||
|
||||
if _, err := ResetFromEnv(path, env, log); err != nil {
|
||||
t.Fatalf("ResetFromEnv: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, secret := range []string{env.APIKey, env.AnthropicAPIKey, "sk-panel-key", "sk-panel-ant"} {
|
||||
if strings.Contains(out, secret) {
|
||||
t.Fatalf("日志里出现了凭据 %q: %s", secret, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, `"source":"config/runtimeconfig"`) {
|
||||
t.Fatalf("日志缺来源标注: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "panel-openai.baseUrl") {
|
||||
t.Fatalf("日志未报告被改回的键(可审计): %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件是「重置为 env 默认」(v0.62,实现见 admin_write_llm_reset.go)的**护栏与
|
||||
// 失败路径**侧:破坏性 `confirm` 门、RBAC 门(owner-only)、审计落库,以及三条
|
||||
// 「明确报错、绝不假成功」的失败路径。语义侧(只改 env 声明的键 / 组集合不变 / 幂等)
|
||||
// 在 admin_write_llm_reset_test.go。
|
||||
//
|
||||
// 用例同样只走临时目录 + 临时 `.env` + 临时 models.json,绝不碰仓库根 `.env` 与 `data/`。
|
||||
|
||||
// TestAdminResetLlmFromEnvRequiresConfirm 钉住破坏性护栏:缺 `confirm`(整个 body 省略、
|
||||
// 空对象、显式 false)都回 400,且**文件一个字节都不动**。
|
||||
func TestAdminResetLlmFromEnvRequiresConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"整个 body 省略", nil},
|
||||
{"空对象", map[string]any{}},
|
||||
{"显式 false", map[string]any{"confirm": false}},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, body := f.post(t, tok, tc.body)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
if readResetPanel(t, f.modelsPath) != f.before {
|
||||
t.Fatal("缺 confirm 的请求改动了面板文件, want 逐字节不变")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvRBAC 钉住角色门:它是**破坏性写**,因此与同页的
|
||||
// `POST /admin/llm`、`/admin/llm/test` 同门 —— **owner-only**:无凭据 401、viewer 403、
|
||||
// **admin 403**(admin 绝不越入 owner),且被拒的请求一个字节都不动。
|
||||
func TestAdminResetLlmFromEnvRBAC(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{})
|
||||
|
||||
if resp, body := f.post(t, "", map[string]any{"confirm": true}); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("匿名 status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
for _, role := range []auth.Role{auth.RoleViewer, auth.RoleAdmin} {
|
||||
role := role
|
||||
tok := signAny(t, f.jwt, "acct", role)
|
||||
if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("%s status = %d, want 403 (body=%s)", role, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
if readResetPanel(t, f.modelsPath) != f.before {
|
||||
t.Fatal("被拒的请求改动了面板文件, want 逐字节不变")
|
||||
}
|
||||
// owner 仍可执行(门只收紧到 owner,不是关掉能力)。
|
||||
resp, body := f.post(t, f.ownerToken(t), map[string]any{"confirm": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("owner status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvAudits 钉住审计:成功落一行 `llm.resetFromEnv` / ok,
|
||||
// 缺 confirm 的尝试落一行 error —— 都进真实 audit_logs(SQLite)。动作名沿用本仓
|
||||
// 既有多词动作的 camelCase 约定(与 llm.addProvider / persona.update 同风格)。
|
||||
func TestAdminResetLlmFromEnvAudits(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := openAuditConn(t)
|
||||
auditStore, err := NewSQLiteAuditStore(ctx, conn, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit store: %v", err)
|
||||
}
|
||||
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{AuditStore: auditStore})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
if resp, body := f.post(t, tok, nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("缺 confirm status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rows := readAuditRows(t, ctx, conn)
|
||||
want := []auditRow{
|
||||
{who: "acct:owner", what: "llm.resetFromEnv", result: "error"},
|
||||
{who: "acct:owner", what: "llm.resetFromEnv", result: "ok"},
|
||||
}
|
||||
if len(rows) != len(want) {
|
||||
t.Fatalf("audit_logs 行数 = %d, want %d (%+v)", len(rows), len(want), rows)
|
||||
}
|
||||
for i := range want {
|
||||
if rows[i] != want[i] {
|
||||
t.Fatalf("audit_logs[%d] = %+v, want %+v", i, rows[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvUnreadableEnvSource 钉住失败路径之一:`.env` 源不可读 ⇒
|
||||
// 明确 500 `env_defaults_unreadable`,面板文件一个字节都不动(绝不假成功)。
|
||||
func TestAdminResetLlmFromEnvUnreadableEnvSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 目录不是可读的 `.env`:读取必然失败。
|
||||
f := newLlmResetFixture(t, t.TempDir(), resetPanelJSON, Deps{})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
resp, body := f.post(t, tok, map[string]any{"confirm": true})
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if code := errorCode(t, body); code != "env_defaults_unreadable" {
|
||||
t.Fatalf("error code = %q, want env_defaults_unreadable (body=%s)", code, body)
|
||||
}
|
||||
if readResetPanel(t, f.modelsPath) != f.before {
|
||||
t.Fatal("env 源不可读时改动了面板文件, want 逐字节不变")
|
||||
}
|
||||
if f.runtime.Model() != "panel-model" {
|
||||
t.Fatalf("运行中的栈被改动了(model=%q), want 原样", f.runtime.Model())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvMissingPanelFile 钉住失败路径之二:面板配置不存在 ⇒ 明确
|
||||
// 错误(models_unreadable),且**不创建文件**、不假装重置成功。
|
||||
func TestAdminResetLlmFromEnvMissingPanelFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest())
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime, EnvPath: writeResetEnvFile(t)})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm/reset-from-env",
|
||||
map[string]any{"confirm": true}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if code := errorCode(t, body); code != "models_unreadable" {
|
||||
t.Fatalf("error code = %q, want models_unreadable (body=%s)", code, body)
|
||||
}
|
||||
if _, err := os.Stat(modelsPath); !os.IsNotExist(err) {
|
||||
t.Fatal("面板配置不存在时创建了文件, want 什么都不做")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvUnwired 钉住未接线:LLM 栈没注入时明确 500 `llm_unavailable`。
|
||||
func TestAdminResetLlmFromEnvUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EnvPath: writeResetEnvFile(t)})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm/reset-from-env",
|
||||
map[string]any{"confirm": true}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if code := errorCode(t, body); code != "llm_unavailable" {
|
||||
t.Fatalf("error code = %q, want llm_unavailable (body=%s)", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// openAuditConn 在临时目录开一个真实 SQLite 连接(审计落库断言用)。绝不碰 data/。
|
||||
func openAuditConn(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
conn, err := db.Open(context.Background(), filepath.Join(t.TempDir(), "cyrene.db"),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// errorCode 取错误信封里的 code 字段(并顺带断言 message 非空)。
|
||||
func errorCode(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
|
||||
}
|
||||
if env.Error.Message == "" {
|
||||
t.Fatalf("错误信封缺 message: %s", body)
|
||||
}
|
||||
return env.Error.Code
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件钉住「重置为 env 默认」动作(切片 R1,实现见 admin_write_llm_reset.go)的
|
||||
// **语义**侧:只覆盖 `.env` 声明过的键、env 未声明的键原样保留、组集合不增减、幂等。
|
||||
// 护栏(confirm / RBAC / 审计)与失败路径在 admin_write_llm_reset_guard_test.go。
|
||||
//
|
||||
// 用例全部走**临时目录 + 临时 `.env` 文件 + 临时 models.json**:仓库根的 `.env` 与
|
||||
// `data/` 一个字节都不碰。
|
||||
|
||||
const (
|
||||
// resetEnvKey / resetPanelKey 是两个必须**永不回显**的凭据明文。
|
||||
resetEnvKey = "sk-env-reset-plaintext"
|
||||
resetPanelKey = "sk-panel-reset-plaintext"
|
||||
)
|
||||
|
||||
// resetPanelJSON 是面板(models.json)的初始状态:openai 组与 anthropic 组的面板值
|
||||
// 全都与临时 `.env` 的声明不同,且带几把 env 里**没有出处**的键(fallback /
|
||||
// multimodal / maxTokens / reasoning / personaModels)——它们必须原样活下来。
|
||||
const resetPanelJSON = `[
|
||||
{"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-model",
|
||||
"fallback":"panel-fallback","multimodal":true,"maxTokens":4096,
|
||||
"reasoning":{"kind":"vllm"},"personaModels":{"cyrene":"panel-model"}},
|
||||
{"id":"panel-anthropic","provider":"anthropic","baseUrl":"https://panel-anthropic.example","apiKey":"sk-panel-ant",
|
||||
"model":"panel-claude","fallback":"panel-ant-fallback"}
|
||||
]`
|
||||
|
||||
// llmResetFixture 是一次重置用例的运行环境。
|
||||
type llmResetFixture struct {
|
||||
modelsPath string
|
||||
envPath string
|
||||
runtime *model.RuntimeLLM
|
||||
ts *httptest.Server
|
||||
jwt *auth.JWTService
|
||||
before string
|
||||
}
|
||||
|
||||
// newLlmResetFixture 写面板文件 + 临时 `.env`,接上可热重载的 LLM 栈并起测试服务。
|
||||
// envPath 留空 = 用临时 `.env`;「env 源不可读」的用例自己传一个目录路径进来。
|
||||
func newLlmResetFixture(t *testing.T, envPath, panel string, deps Deps) *llmResetFixture {
|
||||
t.Helper()
|
||||
modelsPath, before := writeResetPanelFile(t, panel)
|
||||
if envPath == "" {
|
||||
envPath = writeResetEnvFile(t)
|
||||
}
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest())
|
||||
deps.LLM = runtime
|
||||
deps.EnvPath = envPath
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
return &llmResetFixture{modelsPath: modelsPath, envPath: envPath, runtime: runtime, ts: ts, jwt: jwt, before: before}
|
||||
}
|
||||
|
||||
// post 发一次重置请求(token 为空 = 匿名)。
|
||||
func (f *llmResetFixture) post(t *testing.T, tok string, body map[string]any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
return doReq(t, http.MethodPost, f.ts.URL+"/api/v1/admin/llm/reset-from-env", body, tok)
|
||||
}
|
||||
|
||||
// ownerToken 签发 owner token(每个用例自带一个,避免共享状态)。
|
||||
func (f *llmResetFixture) ownerToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
return signAny(t, f.jwt, "acct", auth.RoleOwner)
|
||||
}
|
||||
|
||||
// writeResetEnvFile 写一份临时 `.env` 默认层并返回其路径(`LLM_PROVIDER` 缺省按
|
||||
// 标签默认 openai;两族都声明,好让用例分辨「按组自己的 provider 选族」)。
|
||||
func writeResetEnvFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
content := strings.Join([]string{
|
||||
"# 重置用例的临时 env 默认层(绝不写到仓库根 .env)",
|
||||
"LLM_BASE_URL=https://env.example/v1",
|
||||
"LLM_API_KEY=" + resetEnvKey,
|
||||
"LLM_MODEL=env-model",
|
||||
"LLM_CONTEXT_WINDOW=131072",
|
||||
"ANTHROPIC_BASE_URL=https://env-anthropic.example",
|
||||
"ANTHROPIC_API_KEY=sk-env-anthropic-plaintext",
|
||||
"ANTHROPIC_MODEL=env-claude",
|
||||
"",
|
||||
}, "\n")
|
||||
path := filepath.Join(t.TempDir(), "reset.env")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("写临时 .env: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// writeResetPanelFile 写一份面板 models.json,返回路径与文件原文。
|
||||
func writeResetPanelFile(t *testing.T, panel string) (string, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
if err := os.WriteFile(path, []byte(panel), 0o600); err != nil {
|
||||
t.Fatalf("写面板 models.json: %v", err)
|
||||
}
|
||||
return path, readResetPanel(t, path)
|
||||
}
|
||||
|
||||
// readResetPanel 读回面板文件原文(幂等/不动的断言必须比字节,不能只看解出来的组)。
|
||||
func readResetPanel(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读回 %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// llmResetResponse 是 POST /api/v1/admin/llm/reset-from-env 的响应体:字段名逐字
|
||||
// 对齐网关 DTO(llmResetGroup / llmResetSkip)。
|
||||
type llmResetResponse struct {
|
||||
Reset []struct {
|
||||
Group string `json:"group"`
|
||||
Keys []string `json:"keys"`
|
||||
} `json:"reset"`
|
||||
Skipped []struct {
|
||||
Group string `json:"group"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"skipped"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// decodeResetResponse 解响应体的 data 段。
|
||||
func decodeResetResponse(t *testing.T, body []byte) llmResetResponse {
|
||||
t.Helper()
|
||||
var res llmResetResponse
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode reset response: %v (body=%s)", err, body)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// resetKeys 取某个组被改回的键名。
|
||||
func resetKeys(t *testing.T, res llmResetResponse, group string) []string {
|
||||
t.Helper()
|
||||
for _, g := range res.Reset {
|
||||
if g.Group == group {
|
||||
return g.Keys
|
||||
}
|
||||
}
|
||||
t.Fatalf("组 %q 不在 reset 列表里: %+v", group, res.Reset)
|
||||
return nil
|
||||
}
|
||||
|
||||
// diskGroups 从磁盘读回组(真实落盘断言)。
|
||||
func diskGroups(t *testing.T, path string) []runtimeconfig.Group {
|
||||
t.Helper()
|
||||
groups, warn := runtimeconfig.LoadModels(path, config.LLMConfig{})
|
||||
if warn != nil {
|
||||
t.Fatalf("LoadModels: warn = %v", warn)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func TestAdminResetLlmFromEnvOnlyResetsEnvDeclaredKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
resp, body := f.post(t, tok, map[string]any{"confirm": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
res := decodeResetResponse(t, body)
|
||||
if res.Path != f.modelsPath {
|
||||
t.Fatalf("path = %q, want %q", res.Path, f.modelsPath)
|
||||
}
|
||||
// 只回**真的被改回**的键(provider 在 env 默认层里也有声明,但面板值已一致 ⇒ 不算改回)。
|
||||
wantKeys := []string{"baseUrl", "apiKey", "model", "contextWindow"}
|
||||
if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, wantKeys) {
|
||||
t.Fatalf("panel-openai 改回的键 = %v, want %v", got, wantKeys)
|
||||
}
|
||||
if got := resetKeys(t, res, "panel-anthropic"); !equalStringSlices(got, wantKeys) {
|
||||
t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族)", got, wantKeys)
|
||||
}
|
||||
if len(res.Skipped) != 0 {
|
||||
t.Fatalf("skipped = %+v, want 空(两组都有键被改回)", res.Skipped)
|
||||
}
|
||||
|
||||
// 磁盘真值:env 值覆盖,env 未声明的键原样保留。
|
||||
groups := diskGroups(t, f.modelsPath)
|
||||
if len(groups) != 2 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" {
|
||||
t.Fatalf("组集合/顺序 = %+v, want 原样的两组", groups)
|
||||
}
|
||||
g := groups[0]
|
||||
if g.BaseURL != "https://env.example/v1" || g.APIKey != resetEnvKey || g.Model != "env-model" || g.ContextWindow != 131072 {
|
||||
t.Fatalf("panel-openai = %+v, want env 的 baseUrl/apiKey/model/contextWindow", g)
|
||||
}
|
||||
if g.Fallback != "panel-fallback" || !g.Multimodal || g.MaxTokens != 4096 ||
|
||||
g.Reasoning.Kind != "vllm" || g.PersonaModels["cyrene"] != "panel-model" {
|
||||
t.Fatalf("env 未声明的键被动了: %+v", g)
|
||||
}
|
||||
ant := groups[1]
|
||||
if ant.BaseURL != "https://env-anthropic.example" || ant.APIKey != "sk-env-anthropic-plaintext" ||
|
||||
ant.Model != "env-claude" || ant.Fallback != "panel-ant-fallback" {
|
||||
t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值 + 保留 fallback", ant)
|
||||
}
|
||||
|
||||
// 运行中的栈真的换了配置(热重载,无需重启):生效组(下标 0)已退回 env 基线。
|
||||
if f.runtime.Group().ID != "panel-openai" || f.runtime.Model() != "env-model" {
|
||||
t.Fatalf("runtime group=%q model=%q, want 热重载到 env 基线", f.runtime.Group().ID, f.runtime.Model())
|
||||
}
|
||||
|
||||
// 凭据纪律:两个 key 的明文都不在响应体里(只回键名)。
|
||||
for _, secret := range []string{resetEnvKey, resetPanelKey, "sk-env-anthropic-plaintext", "sk-panel-ant"} {
|
||||
if strings.Contains(string(body), secret) {
|
||||
t.Fatalf("响应体回显了凭据 %q: %s", secret, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSlices 比对两份字符串切片(顺序敏感:键顺序即 groupFillSpecs 顺序)。
|
||||
func equalStringSlices(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvKeepsGroupSet 钉住「组集合不增减」:面板删掉的 anthropic 组
|
||||
// 不因 `.env` 仍声明它而复活,面板新增的组也不被删除(顺序一并保持)。
|
||||
func TestAdminResetLlmFromEnvKeepsGroupSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
panel := `[
|
||||
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-extra-model"}
|
||||
]`
|
||||
f := newLlmResetFixture(t, "", panel, Deps{})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
resp, body := f.post(t, tok, map[string]any{"confirm": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if got := resetKeys(t, decodeResetResponse(t, body), "panel-extra"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model", "contextWindow"}) {
|
||||
t.Fatalf("panel-extra 改回的键 = %v, want [baseUrl apiKey model contextWindow]", got)
|
||||
}
|
||||
groups := diskGroups(t, f.modelsPath)
|
||||
if len(groups) != 1 || groups[0].ID != "panel-extra" {
|
||||
t.Fatalf("组集合 = %+v, want 只有面板那一组(不复活被删的 anthropic 组)", groups)
|
||||
}
|
||||
if _, ok := runtimeconfig.Find(groups, runtimeconfig.ProviderAnthropic); ok {
|
||||
t.Fatal("`.env` 声明的 anthropic 组被复活了, want 不新增组")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvIdempotent 钉住幂等:第二次调用没有任何键要改,于是不写盘、
|
||||
// 两组都报「已与 env 一致」。
|
||||
func TestAdminResetLlmFromEnvIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("第一次 status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
afterFirst := readResetPanel(t, f.modelsPath)
|
||||
|
||||
resp, body := f.post(t, tok, map[string]any{"confirm": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("第二次 status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
res := decodeResetResponse(t, body)
|
||||
if len(res.Reset) != 0 {
|
||||
t.Fatalf("reset = %+v, want 空(面板值已与 env 一致)", res.Reset)
|
||||
}
|
||||
if len(res.Skipped) != 2 {
|
||||
t.Fatalf("skipped = %+v, want 两组都报「已一致」", res.Skipped)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
if s.Reason != runtimeconfig.SkipReasonAlreadyDefault {
|
||||
t.Fatalf("跳过原因 = %q, want %q", s.Reason, runtimeconfig.SkipReasonAlreadyDefault)
|
||||
}
|
||||
}
|
||||
if readResetPanel(t, f.modelsPath) != afterFirst {
|
||||
t.Fatal("无键可改时文件被改动了, want 逐字节不变")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare 钉住面板上那条跳过原因的诚实性:
|
||||
// `.env` 只配了扁平 openai 块时,anthropic 族没有可改回的键,跳过原因必须是
|
||||
// 「`.env` 未声明该组的默认键」而不是误导性的「面板值已一致」。
|
||||
func TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "openai-only.env")
|
||||
content := "LLM_BASE_URL=https://env.example/v1\nLLM_API_KEY=" + resetEnvKey + "\nLLM_MODEL=env-model\n"
|
||||
if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("写临时 .env: %v", err)
|
||||
}
|
||||
f := newLlmResetFixture(t, envPath, resetPanelJSON, Deps{})
|
||||
tok := f.ownerToken(t)
|
||||
|
||||
resp, body := f.post(t, tok, map[string]any{"confirm": true})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
res := decodeResetResponse(t, body)
|
||||
if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model"}) {
|
||||
t.Fatalf("panel-openai 改回的键 = %v, want [baseUrl apiKey model](扁平块声明的就是这三个)", got)
|
||||
}
|
||||
reason := ""
|
||||
for _, s := range res.Skipped {
|
||||
if s.Group == "panel-anthropic" {
|
||||
reason = s.Reason
|
||||
}
|
||||
}
|
||||
if reason != runtimeconfig.SkipReasonNoDefaults {
|
||||
t.Fatalf("panel-anthropic 的跳过原因 = %q, want %q", reason, runtimeconfig.SkipReasonNoDefaults)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ type Deps struct {
|
||||
// 可选:为 nil 时管理 /llm 端点回退到环境配置,使
|
||||
// 既有的网关测试继续可用。
|
||||
LLM *model.RuntimeLLM
|
||||
// EnvPath 是进程启动时解析的 `.env` 文件路径(组合根传给 config.Load 的同一个)。
|
||||
// 「重置为 env 默认」动作(切片 R1)按**既有 config 解析入口**重读 env 默认层,
|
||||
// 网关自己绝不 fopen `.env`。空表示只看进程环境变量(与 config.Load("") 的
|
||||
// 约定一致,生产走 systemd/env_file 时就是这种形态)。可选。
|
||||
EnvPath string
|
||||
// Channels 是运行时可编辑的渠道适配器配置(channels.json)。
|
||||
// 可选:为 nil 时管理 /channels/config 端点返回明确的
|
||||
// not-configured 错误,与 LLM 栈未设置时的行为一致。
|
||||
|
||||
@@ -85,6 +85,7 @@ var ownerOnlyRoutes = []rbacRoute{
|
||||
{"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"},
|
||||
{"llm POST", http.MethodPost, "/api/v1/admin/llm"},
|
||||
{"llm/test", http.MethodPost, "/api/v1/admin/llm/test"},
|
||||
{"llm/reset-from-env", http.MethodPost, "/api/v1/admin/llm/reset-from-env"},
|
||||
{"memories POST", http.MethodPost, "/api/v1/admin/memories"},
|
||||
{"memories/search", http.MethodPost, "/api/v1/admin/memories/search"},
|
||||
{"rag POST", http.MethodPost, "/api/v1/admin/rag"},
|
||||
|
||||
@@ -206,6 +206,11 @@ func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(htt
|
||||
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
|
||||
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
|
||||
mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm)))
|
||||
// 「重置为 env 默认」(切片 R1,v0.62):把面板里 `.env` 声明过的键覆盖回 env 值,
|
||||
// 组集合不增减。**仅 owner**——它是破坏性写(覆盖面板对这些键的修改),门与同页的
|
||||
// `POST /admin/llm`、`/admin/llm/test` 一致,绝不比同族写端点更宽。写操作,带审计
|
||||
// 动作 `llm.resetFromEnv`;破坏性另由请求体的 `confirm:true` 兜住。
|
||||
mux.Handle("POST /api/v1/admin/llm/reset-from-env", ownerAuth(http.HandlerFunc(s.adminResetLlmFromEnv)))
|
||||
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
|
||||
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
||||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
||||
|
||||
@@ -71,7 +71,10 @@ type Server struct {
|
||||
dataDir string
|
||||
// llm 是可热切换的提供方栈(models.json)。为 nil 时 admin 的 /llm
|
||||
// 端点回退到环境变量配置。它是可选的,以便未接它的测试仍能工作。
|
||||
llm *model.RuntimeLLM
|
||||
llm *model.RuntimeLLM
|
||||
// envPath 是启动时解析的 `.env` 路径(见 Deps.EnvPath):只供
|
||||
// 「重置为 env 默认」动作按既有解析入口重读 env 默认层,网关不 fopen `.env`。
|
||||
envPath string
|
||||
channels ChannelConfigStore
|
||||
// plugins 是可在运行时安装的外部工具提供方管理器。见
|
||||
// Deps.Plugins。
|
||||
@@ -126,6 +129,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
streaming: deps.Streaming,
|
||||
frontend: deps.Frontend,
|
||||
llm: deps.LLM,
|
||||
envPath: deps.EnvPath,
|
||||
channels: deps.Channels,
|
||||
plugins: deps.Plugins,
|
||||
ownerRegistrar: deps.OwnerRegistrar,
|
||||
|
||||
Reference in new issue
Block a user