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" ) // 阶段 5 新增的模型配置字段(reasoning / personaModels / personaProviders / // multimodal)在管理面板与 admin DTO 上的读写锁定:读侧必须如实回填(否则 // 面板一保存就把键抹掉),写侧必须落库,非法值必须 400 显式报错。 // llmAddResult 是 POST /api/v1/admin/llm 的响应体。 type llmAddResult struct { Provider AdminLlmProvider `json:"provider"` Persisted bool `json:"persisted"` Path string `json:"path"` } // postLlmProvider 发一次 provider 保存请求并返回响应。 func postLlmProvider(t *testing.T, ts *httptest.Server, tok string, body map[string]any) (*http.Response, []byte) { t.Helper() return doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", body, tok) } // llmEnvForTest 是测试用的 .env 兜底 LLM 块。 func llmEnvForTest() config.LLMConfig { return config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"} } // llmTestEnv 起一个接线的 LLM 栈 + models.json 落盘路径,返回 // (models.json 路径, 测试服务, owner token)。 func llmTestEnv(t *testing.T) (string, *httptest.Server, string) { t.Helper() modelsPath := filepath.Join(t.TempDir(), "models.json") runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest()) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) return modelsPath, ts, signAny(t, jwt, "acct", auth.RoleOwner) } // storedGroups 直接从磁盘读回 models.json(真实落盘断言,绕过运行中的栈)。 func storedGroups(t *testing.T, path string) ([]runtimeconfig.Group, string) { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } var groups []runtimeconfig.Group if err := json.Unmarshal(raw, &groups); err != nil { t.Fatalf("decode %s: %v", path, err) } return groups, string(raw) } // findStored 返回磁盘上 id 对应的分组。 func findStored(t *testing.T, groups []runtimeconfig.Group, id string) runtimeconfig.Group { t.Helper() g, ok := runtimeconfig.Find(groups, id) if !ok { t.Fatalf("group %q not on disk: %+v", id, groups) } return g } func TestAdminAddLlmProviderPersistsNewFields(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) resp, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "vllm-qwen", "provider": "openai", "baseUrl": "https://vllm.local/v1", "apiKey": "sk-vllm", "model": "qwen3-max", "multimodal": true, "reasoning": map[string]any{"kind": "vllm"}, "personaModels": map[string]any{"cyrene": "qwen3-max", "natsume": "qwen3-32b"}, "personaProviders": map[string]any{"natsume": "anthropic"}, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } var res llmAddResult if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode add: %v", err) } p := res.Provider if p.Reasoning == nil || p.Reasoning.Kind != "vllm" || !p.Multimodal || p.PersonaModels["cyrene"] != "qwen3-max" || p.PersonaModels["natsume"] != "qwen3-32b" || p.PersonaProviders["natsume"] != "anthropic" { t.Fatalf("saved provider = %+v, want the new fields echoed back", p) } // models.json 真的含这些键与值(不只是内存栈里对)。 groups, raw := storedGroups(t, modelsPath) g := findStored(t, groups, "vllm-qwen") if g.Reasoning.Kind != "vllm" || !g.Multimodal || g.PersonaModels["cyrene"] != "qwen3-max" || g.PersonaProviders["natsume"] != "anthropic" { t.Fatalf("stored group = %+v, want reasoning/persona routing persisted", g) } for _, key := range []string{`"reasoning"`, `"personaModels"`, `"personaProviders"`, `"multimodal"`} { if !strings.Contains(raw, key) { t.Fatalf("models.json missing key %s: %s", key, raw) } } // GET /admin/llm 同样回填这些字段(面板靠它播种表单)。 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 (body=%s)", gResp.StatusCode, gBody) } var cfg AdminLlmConfig if err := json.Unmarshal(decodeBody(t, gBody)["data"], &cfg); err != nil { t.Fatalf("decode get: %v", err) } got, ok := findDTO(cfg.Providers, "vllm-qwen") if !ok || got.Reasoning == nil || got.Reasoning.Kind != "vllm" || got.PersonaModels["cyrene"] != "qwen3-max" { t.Fatalf("GET providers = %+v, want vllm-qwen with reasoning vllm and persona route", cfg.Providers) } } // findDTO 在线上列表里按 id 找分组。 func findDTO(list []AdminLlmProvider, id string) (AdminLlmProvider, bool) { for _, p := range list { if p.ID == id { return p, true } } return AdminLlmProvider{}, false } func TestAdminAddLlmProviderPartialEditKeepsNewFields(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) seed, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "vllm-qwen", "provider": "openai", "baseUrl": "https://vllm.local/v1", "apiKey": "sk-vllm", "model": "qwen3-max", "multimodal": true, "reasoning": map[string]any{"kind": "vllm"}, "personaModels": map[string]any{"cyrene": "qwen3-max"}, "personaProviders": map[string]any{"cyrene": "openai"}, }) if seed.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) } // 只改 model:新字段一个都不在请求里,必须原样保留。 resp, body2 := postLlmProvider(t, ts, tok, map[string]any{"id": "vllm-qwen", "model": "qwen3-vl"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("edit status = %d, want 201 (body=%s)", resp.StatusCode, body2) } groups, _ := storedGroups(t, modelsPath) g := findStored(t, groups, "vllm-qwen") if g.Model != "qwen3-vl" || g.Reasoning.Kind != "vllm" || !g.Multimodal || g.PersonaModels["cyrene"] != "qwen3-max" || g.PersonaProviders["cyrene"] != "openai" { t.Fatalf("stored group = %+v, want model updated and new fields preserved", g) } } func TestAdminAddLlmProviderClearsNewFieldsExplicitly(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) seed, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "vllm-qwen", "provider": "openai", "baseUrl": "https://vllm.local/v1", "apiKey": "sk-vllm", "model": "qwen3-max", "multimodal": true, "reasoning": map[string]any{"kind": "vllm"}, "personaModels": map[string]any{"cyrene": "qwen3-max"}, "personaProviders": map[string]any{"cyrene": "openai"}, }) if seed.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) } // 显式清空:reasoning 给出但 kind 空 = 回到「未声明」,两张路由表给空对象 // = 整表替换成空(面板据此能删掉一条已存路由)。 resp, body2 := postLlmProvider(t, ts, tok, map[string]any{ "id": "vllm-qwen", "reasoning": map[string]any{}, "personaModels": map[string]any{}, "personaProviders": map[string]any{}, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("clear status = %d, want 201 (body=%s)", resp.StatusCode, body2) } groups, raw := storedGroups(t, modelsPath) g := findStored(t, groups, "vllm-qwen") if g.Reasoning.Kind != "" || len(g.PersonaModels) != 0 || len(g.PersonaProviders) != 0 { t.Fatalf("stored group = %+v, want reasoning/persona routing cleared", g) } if strings.Contains(raw, "personaModels") || strings.Contains(raw, `"kind"`) { t.Fatalf("cleared keys must be omitted from models.json: %s", raw) } } func TestAdminAddLlmProviderRejectsInvalidNewFields(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) seed, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "base-group", "provider": "openai", "baseUrl": "https://api.example.com/v1", "apiKey": "sk-base", "model": "m1", "reasoning": map[string]any{"kind": "auto"}, }) if seed.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) } cases := []struct { name string body map[string]any }{ {"未知 reasoning.kind", map[string]any{"id": "base-group", "reasoning": map[string]any{"kind": "gemini"}}}, {"anthropic 组不得声明 openai 系形状", map[string]any{ "id": "anthropic-group", "provider": "anthropic", "baseUrl": "https://api.anthropic.com", "model": "claude-sonnet-4", "reasoning": map[string]any{"kind": "vllm"}, }}, {"personaProviders 非法协议名", map[string]any{ "id": "base-group", "personaProviders": map[string]any{"cyrene": "gemini"}, }}, {"personaModels 空值", map[string]any{"id": "base-group", "personaModels": map[string]any{"cyrene": " "}}}, {"personaModels 空 persona id", map[string]any{"id": "base-group", "personaModels": map[string]any{"": "m2"}}}, {"persona 键去空白后重复", map[string]any{ "id": "base-group", "personaModels": map[string]any{"cyrene": "m2", " cyrene ": "m3"}, }}, } for _, tc := range cases { resp, respBody := postLlmProvider(t, ts, tok, tc.body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("%s: status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, respBody) } if errObj := decodeBody(t, respBody)["error"]; len(errObj) == 0 { t.Fatalf("%s: 400 without an error envelope (body=%s)", tc.name, respBody) } } // 被拒的请求绝不落库:磁盘上仍是种子分组(auto / 无路由表),也没有 // 出现 anthropic-group 这样的半成品。 groups, raw := storedGroups(t, modelsPath) g := findStored(t, groups, "base-group") if g.Reasoning.Kind != runtimeconfig.ReasoningKindAuto || len(g.PersonaModels) != 0 || len(g.PersonaProviders) != 0 { t.Fatalf("seed group changed by a rejected write: %+v", g) } if _, ok := runtimeconfig.Find(groups, "anthropic-group"); ok { t.Fatalf("a rejected group was persisted: %+v", groups) } if strings.Contains(raw, "gemini") { t.Fatalf("a rejected value leaked into models.json: %s", raw) } } func TestAdminGetLlmReportsFieldValueDomain(t *testing.T) { t.Parallel() _, ts, tok := llmTestEnv(t) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var cfg AdminLlmConfig if err := json.Unmarshal(decodeBody(t, body)["data"], &cfg); err != nil { t.Fatalf("decode get: %v", err) } // 面板的候选直接来自后端:与配置层校验同一份清单。 if strings.Join(cfg.ReasoningKinds, ",") != strings.Join(runtimeconfig.ReasoningKinds(), ",") { t.Fatalf("reasoningKinds = %v, want %v", cfg.ReasoningKinds, runtimeconfig.ReasoningKinds()) } if strings.Join(cfg.PersonaProviderOptions, ",") != strings.Join(runtimeconfig.ProviderNames(), ",") { t.Fatalf("personaProviderOptions = %v, want %v", cfg.PersonaProviderOptions, runtimeconfig.ProviderNames()) } // 未接线 models.json 时也要如实回填(env 兜底分组同样带 reasoning 块)。 if len(cfg.Providers) == 0 || cfg.Providers[0].Reasoning == nil { t.Fatalf("env-fallback provider = %+v, want a reasoning block", cfg.Providers) } }