feat(config): 运行时配置体系①——LLM 多厂商独立组落 models.json + 面板落盘 + 热更新
- runtimeconfig/models.go: models.json 承载 [{id,provider,baseUrl,apiKey,model,fallback?,multimodal?}] 多厂商独立组(首项=生效)
- LoadModels 优先文件/回退env(GroupFromEnv), SaveModels(0600+tmp原子写), Activate/Upsert/MaskedKey脱敏
- 面板 POST /admin/llm 落盘 models.json(取代temporary内存) + GET 列组(脱敏)
- runtime/model/runtime.go: RuntimeLLM(adapter+router) atomic快照可热替换, Reload 重建不中断进行中请求; per-role路由用接口
- wire 用 NewRuntimeLLM(adapter+router), 保留 a.modelAdapter(=runtime stack, 审查模型随热更新)
- .env 退兜底(.env.example注明); apiKey不打印/Config()脱敏/热更新不中断
- 全仓 go build/vet/test 绿(54包)+交叉编译+前端build绿(79模块); api-client v0.15已同步
- 遗留: vision ModelInfo 仍读静态env(后续纳入); 保存组=生效组(第一版语义)
This commit is contained in:
1 parent
8cde0162e6
commit
2d7151f979
16 files changed
+1079
-108
No files matched your search
@@ -0,0 +1,175 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
func envCfg() config.LLMConfig {
|
||||
return config.LLMConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
APIKey: "sk-env",
|
||||
Model: "gpt-4o",
|
||||
Fallback: "gpt-4o-mini",
|
||||
}
|
||||
}
|
||||
|
||||
func writeModelsFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write models.json: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadModels_FileValid(t *testing.T) {
|
||||
path := writeModelsFile(t, `[
|
||||
{"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o","fallback":"gpt-4o-mini","multimodal":true}
|
||||
]`)
|
||||
groups, warn := LoadModels(path, envCfg())
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v, want nil", warn)
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("len(groups) = %d, want 1", len(groups))
|
||||
}
|
||||
g := groups[0]
|
||||
if g.ID != "openai" || g.Provider != ProviderOpenAI || g.Model != "gpt-4o" || !g.Multimodal {
|
||||
t.Fatalf("group = %+v, want openai/gpt-4o/multimodal", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModels_MissingFileFallsBackEnv(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nonexistent.json")
|
||||
groups, warn := LoadModels(path, envCfg())
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v, want nil (missing file is the normal case)", warn)
|
||||
}
|
||||
if len(groups) != 1 || groups[0].Model != "gpt-4o" || groups[0].APIKey != "sk-env" {
|
||||
t.Fatalf("groups = %+v, want env fallback gpt-4o/sk-env", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModels_InvalidFallsBackEnvWithWarn(t *testing.T) {
|
||||
path := writeModelsFile(t, `{ not json`)
|
||||
groups, warn := LoadModels(path, envCfg())
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an invalid file")
|
||||
}
|
||||
if len(groups) != 1 || groups[0].APIKey != "sk-env" {
|
||||
t.Fatalf("groups = %+v, want env fallback", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModels_EmptyArrayFallsBackEnv(t *testing.T) {
|
||||
path := writeModelsFile(t, `[]`)
|
||||
groups, warn := LoadModels(path, envCfg())
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an empty file")
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups = %+v, want env fallback single group", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
valid := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("valid group: %v", err)
|
||||
}
|
||||
cases := []Group{
|
||||
{ID: "", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"},
|
||||
{ID: "a", Provider: "bogus", BaseURL: "https://x", Model: "m"},
|
||||
{ID: "a", Provider: ProviderOpenAI, BaseURL: "", Model: "m"},
|
||||
{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: ""},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Fatalf("case %d failed to validate: %+v", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToLLMConfig(t *testing.T) {
|
||||
g := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m", Fallback: "f"}
|
||||
cfg := g.ToLLMConfig()
|
||||
if cfg.Provider != ProviderOpenAI || cfg.BaseURL != "https://x" || cfg.APIKey != "sk" || cfg.Model != "m" || cfg.Fallback != "f" {
|
||||
t.Fatalf("ToLLMConfig = %+v", cfg)
|
||||
}
|
||||
ag := Group{ID: "b", Provider: ProviderAnthropic, BaseURL: "https://y", APIKey: "sk2", Model: "claude"}
|
||||
acfg := ag.ToLLMConfig()
|
||||
if acfg.Provider != ProviderAnthropic || acfg.AnthropicBaseURL != "https://y" || acfg.AnthropicModel != "claude" {
|
||||
t.Fatalf("anthropic ToLLMConfig = %+v", acfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedKey(t *testing.T) {
|
||||
empty := Group{APIKey: ""}.MaskedKey()
|
||||
if empty != "" {
|
||||
t.Fatalf("empty key masked = %q, want empty", empty)
|
||||
}
|
||||
short := Group{APIKey: "abc"}.MaskedKey()
|
||||
if short != "****" {
|
||||
t.Fatalf("short key masked = %q, want ****", short)
|
||||
}
|
||||
long := Group{APIKey: "sk-abcdef1234"}.MaskedKey()
|
||||
if long != "****1234" || strings.Contains(long, "sk-") {
|
||||
t.Fatalf("masked = %q, want ****1234 without the prefix", long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivate(t *testing.T) {
|
||||
base := []Group{
|
||||
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", Model: "gpt-4o"},
|
||||
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude"},
|
||||
}
|
||||
// New group is prepended (becomes the active index 0).
|
||||
out := Activate(base, Group{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://c", Model: "ds"})
|
||||
if len(out) != 3 || out[0].ID != "deepseek" || out[1].ID != "openai" || out[2].ID != "anthropic" {
|
||||
t.Fatalf("Activate new = %+v", out)
|
||||
}
|
||||
// An existing group is replaced and promoted to the front.
|
||||
out2 := Activate(out, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o-new"})
|
||||
if len(out2) != 3 || out2[0].ID != "openai" || out2[0].Model != "gpt-4o-new" || out2[1].ID != "deepseek" {
|
||||
t.Fatalf("Activate existing = %+v", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
in := []Group{
|
||||
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-secret", Model: "gpt-4o", Multimodal: true},
|
||||
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-ant", Model: "claude"},
|
||||
}
|
||||
if err := SaveModels(path, in); err != nil {
|
||||
t.Fatalf("SaveModels: %v", err)
|
||||
}
|
||||
groups, warn := LoadModels(path, envCfg())
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v", warn)
|
||||
}
|
||||
if len(groups) != 2 || groups[0].APIKey != "sk-secret" || groups[1].APIKey != "sk-ant" {
|
||||
t.Fatalf("round trip = %+v", groups)
|
||||
}
|
||||
// The key stays in the file (it is the runtime config), but never leaks into
|
||||
// the masked view.
|
||||
if got := groups[0].MaskedKey(); got != "****cret" {
|
||||
t.Fatalf("masked key = %q, want ****cret", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveModels_RejectsInvalid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
if err := SaveModels(path, []Group{{ID: "", Provider: "x", BaseURL: "https://a", Model: "m"}}); err == nil {
|
||||
t.Fatal("expected error saving an invalid group")
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("file should not be written when validation fails")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user