- 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(后续纳入); 保存组=生效组(第一版语义)
176 lines
6.0 KiB
Go
176 lines
6.0 KiB
Go
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")
|
|
}
|
|
}
|