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
@@ -6,14 +6,19 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"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/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
func gwTestLogger() *slog.Logger {
|
||||
@@ -209,6 +214,125 @@ func TestAdminIndexRagUnwired501(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newLlmRuntimeForGateway builds a hot-swappable RuntimeLLM pointing at a fresh
|
||||
// temp models.json, seeded by the env config. It is the real stack the admin
|
||||
// write endpoint persists to and reloads.
|
||||
func newLlmRuntimeForGateway(t *testing.T, modelsPath string, env config.LLMConfig) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
return model.NewRuntimeLLM(modelsPath, env, gwTestLogger())
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"}
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Provider AdminLlmProvider `json:"provider"`
|
||||
Persisted bool `json:"persisted"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode add: %v", err)
|
||||
}
|
||||
if !res.Persisted || res.Path != modelsPath || res.Provider.Model != "deepseek-chat" {
|
||||
t.Fatalf("add res = %+v", res)
|
||||
}
|
||||
|
||||
// models.json now holds the saved group (promoted to index 0 = active).
|
||||
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
|
||||
if warn != nil {
|
||||
t.Fatalf("load models warn = %v", warn)
|
||||
}
|
||||
if len(groups) == 0 || groups[0].ID != "deepseek" || groups[0].APIKey != "sk-ds" {
|
||||
t.Fatalf("file groups = %+v, want deepseek first with sk-ds", groups)
|
||||
}
|
||||
|
||||
// The running stack reflects the new active provider/model (hot reload).
|
||||
if runtime.Provider() != "openai" || runtime.Model() != "deepseek-chat" {
|
||||
t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-chat", runtime.Provider(), runtime.Model())
|
||||
}
|
||||
|
||||
// GET /admin/llm reflects the saved group and never leaks any API key.
|
||||
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok)
|
||||
if gResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status = %d, want 200", gResp.StatusCode)
|
||||
}
|
||||
var list AdminLlmConfig
|
||||
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(list.Providers) == 0 || list.Providers[0].Model != "deepseek-chat" {
|
||||
t.Fatalf("list = %+v, want deepseek-chat first", list.Providers)
|
||||
}
|
||||
if strings.Contains(string(gBody), "sk-ds") || strings.Contains(string(gBody), "sk-env") {
|
||||
t.Fatalf("API key leaked in GET /admin/llm: %s", gBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
env := config.LLMConfig{Provider: "openai", BaseURL: "https://x", APIKey: "sk", Model: "m"}
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Saving an anthropic group makes it the active provider/model.
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"provider": "anthropic", "baseUrl": "https://api.anthropic.com", "apiKey": "sk-ant", "model": "claude-sonnet-4"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if runtime.Provider() != "anthropic" || runtime.Model() != "claude-sonnet-4" {
|
||||
t.Fatalf("runtime Provider=%q Model=%q, want anthropic/claude-sonnet-4", runtime.Provider(), runtime.Model())
|
||||
}
|
||||
// The saved group is active (index 0) and anthropic.
|
||||
if runtime.Group().Provider != "anthropic" {
|
||||
t.Fatalf("active group = %+v, want anthropic", runtime.Group())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
modelsPath := filepath.Join(t.TempDir(), "models.json")
|
||||
runtime := newLlmRuntimeForGateway(t, modelsPath, config.LLMConfig{})
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Missing model.
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"baseUrl": "https://x"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("missing model status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
// Invalid provider.
|
||||
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"provider": "bogus", "baseUrl": "https://x", "model": "m"}, tok)
|
||||
if resp2.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid provider status = %d, want 400", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddLlmProviderUnwired501(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no LLM stack wired
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
||||
map[string]any{"baseUrl": "https://x", "model": "m"}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unwired status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListSessionsHistoryFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHistoryStoreForGateway(t)
|
||||
|
||||
Reference in new issue
Block a user