package main import ( "bytes" "io" "log/slog" "os" "path/filepath" "strings" "testing" "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" ) // 启动补齐接线(materializeModels)的契约:成功时面板专用配置文件在**模型栈构建 // 之前**就位(NewRuntimeLLM 紧接着读它);任何失败都只记日志,**不阻断启动**。 func TestMaterializeModelsCreatesPanelFileAtBoot(t *testing.T) { path := filepath.Join(t.TempDir(), "models.json") env := config.LLMConfig{Provider: "openai", BaseURL: "https://x/v1", APIKey: "sk-x", Model: "m"} var buf bytes.Buffer materializeModels(path, env, slog.New(slog.NewJSONHandler(&buf, nil))) if _, err := os.Stat(path); err != nil { t.Fatalf("启动补齐应写出 models.json: %v", err) } groups, warn := runtimeconfig.LoadModels(path, env) if warn != nil { t.Fatalf("LoadModels: warn = %v", warn) } if len(groups) != 1 || groups[0].Model != "m" || groups[0].APIKey != "sk-x" { t.Fatalf("组 = %+v, want .env 播种的合成组", groups) } if strings.Contains(buf.String(), "sk-x") { t.Fatalf("启动日志泄漏了凭据: %s", buf.String()) } } func TestMaterializeModelsNeverBlocksBoot(t *testing.T) { // 路径不可读/不可写时只记日志:模型栈的既有兜底(LoadModels 回退 .env)仍成立, // 配置文件的一次失败不该让服务起不来。 var buf bytes.Buffer materializeModels(t.TempDir(), config.LLMConfig{Provider: "openai"}, slog.New(slog.NewJSONHandler(&buf, nil))) if !strings.Contains(buf.String(), "models.json 补齐") { t.Fatalf("失败时应有可诊断日志: %s", buf.String()) } } // TestModelsBootPathEndToEnd 复现主人的原始场景(去掉 DB/HTTP 接线,端口与 data/ 都不碰): // `.env` → 启动补齐 → 面板专用配置文件 → **真**模型栈(NewRuntimeLLM);面板改一次 // (经真面板保存用的 runtimeconfig.SaveModels 写回),**再启动一次**不得被 `.env` 打回。 func TestModelsBootPathEndToEnd(t *testing.T) { path := filepath.Join(t.TempDir(), "models.json") env := config.LLMConfig{Provider: "openai", BaseURL: "https://env.example/v1", APIKey: "sk-env", Model: "env-model"} log := slog.New(slog.NewJSONHandler(io.Discard, nil)) // 第一次启动:面板文件不存在 ⇒ 由 .env 播种,模型栈直接读到 env 的模型名。 materializeModels(path, env, log) stack := model.NewRuntimeLLM(path, env, log) if got := stack.Model(); got != "env-model" { t.Fatalf("首次启动模型名 = %q, want env 的 env-model", got) } // 面板把该组改成 panel-model(与面板保存端点同一条写路径),随后热重载。 groups := stack.Groups() groups[0].Model = "panel-model" groups[0].APIKey = "sk-panel" if err := runtimeconfig.SaveModels(path, groups); err != nil { t.Fatalf("面板保存: %v", err) } // 第二次启动:补齐只看**缺失**的键,面板的改动不得被打回 .env 值。 materializeModels(path, env, log) restarted := model.NewRuntimeLLM(path, env, log) if got := restarted.Model(); got != "panel-model" { t.Fatalf("重启后模型名 = %q, want 面板改的 panel-model(被 .env 打回即失败)", got) } if got := restarted.Group().APIKey; got != "sk-panel" { t.Fatalf("重启后 apiKey = %q, want 面板的 sk-panel", got) } }