Files
Cyrene_v2/internal/services/gateway/admin_llm_provider_test.go
T
AskaEth 1ac02bd77e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
  顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
  校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
  (实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
  目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
  **不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
  补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
  ⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
2026-10-04 13:06:54 +08:00

277 lines
11 KiB
Go

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)
}
}