依据 DevDocs/design/04 §3.2「官方核对后的修正」9 条 + 本地 DevDocs/reference/deepseek/facts-for-cyrene.md(官方原句/URL)。 - 🔴 reasoning_effort 白名单改为官方集合 [none, low, high, max] + 别名映射(minimal→low, medium→high, xhigh→high, ultra→max);**删除就近降级**,非法值一律丢弃+WARN。此前 none(关思考) 被降级成 low(开思考) 属语义反转 - 🔴 DeepSeek thinking 开关真发顶层 {"thinking":{"type":"enabled|disabled"}}(官方默认 enabled,不传≠关);此前只打 WARN 导致 mode:off 静默无效 - DeepSeek 的 /anthropic 端点忽略 budget_tokens → 不再对其抬 max_tokens(会误导预算/日志),改为按官方用 output_config.effort - 注释纠正:penalty 属接口级 deprecated(非仅思考模式);top_p 仅思考模式生效且下限 0.95(不加该字段) - tool_choice 雷登记:思考模式下 required/指定 tool 会 400(思考默认开) - 旧模型名清理(deepseek-chat/reasoner 已于 2026-07-24 停用) - 请求体级测试 + 变异验证(把 none 改回 low / 把 thinking 改回只 WARN → 对应断言 FAIL)
402 lines
16 KiB
Go
402 lines
16 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newMemoryStoreForGateway opens an isolated in-memory SQLite DB and builds a
|
|
// real memory.Store over it so the owner write path (which type-asserts to
|
|
// *memory.Store) exercises the real Add.
|
|
func newMemoryStoreForGateway(t *testing.T) *memory.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d, err := db.Open(ctx, ":memory:", gwTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("open in-memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
s, err := memory.NewStore(ctx, d, gwTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("memory.NewStore: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func newRagStoreForGateway(t *testing.T) *knowledge.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d, err := db.Open(ctx, ":memory:", gwTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("open in-memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
ps, err := projectindex.New(ctx, d, gwTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("projectindex.New: %v", err)
|
|
}
|
|
return knowledge.New(knowledge.Deps{Projects: ps, Log: gwTestLogger()})
|
|
}
|
|
|
|
func newHistoryStoreForGateway(t *testing.T) *history.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d, err := db.Open(ctx, ":memory:", gwTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("open in-memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
s, err := history.NewStore(ctx, d, gwTestLogger())
|
|
if err != nil {
|
|
t.Fatalf("history.NewStore: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestAdminAddMemoryWritesAndLists(t *testing.T) {
|
|
t.Parallel()
|
|
store := newMemoryStoreForGateway(t)
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store})
|
|
_ = srv
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
|
map[string]any{"personaId": "cyrene", "content": "她喜欢喝冰美式", "scope": "persona"}, tok)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("add status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var added struct {
|
|
ID string `json:"id"`
|
|
Scope string `json:"scope"`
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &added); err != nil {
|
|
t.Fatalf("decode add: %v (body=%s)", err, body)
|
|
}
|
|
if added.ID == "" || added.Scope != "persona" || added.Text != "她喜欢喝冰美式" {
|
|
t.Fatalf("added = %+v, want persona/她喜欢喝冰美式", added)
|
|
}
|
|
|
|
// The freshly added item is now listed.
|
|
listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok)
|
|
if listResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody)
|
|
}
|
|
var items []MemorySummary
|
|
if err := json.Unmarshal(decodeBody(t, listBody)["data"], &items); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].Text != "她喜欢喝冰美式" || items[0].Scope != "persona" {
|
|
t.Fatalf("items = %+v, want one persona 她喜欢喝冰美式", items)
|
|
}
|
|
}
|
|
|
|
func TestAdminAddMemoryRejectsBadInput(t *testing.T) {
|
|
t.Parallel()
|
|
store := newMemoryStoreForGateway(t)
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
// Blank content.
|
|
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
|
map[string]any{"personaId": "cyrene", "content": " "}, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("blank content status = %d, want 400", resp.StatusCode)
|
|
}
|
|
// Invalid scope.
|
|
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
|
map[string]any{"personaId": "cyrene", "content": "x", "scope": "bogus"}, tok)
|
|
if resp2.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("bogus scope status = %d, want 400", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAdminAddMemoryUnwired501(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no memory store
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
|
map[string]any{"content": "x"}, tok)
|
|
if resp.StatusCode != http.StatusNotImplemented {
|
|
t.Fatalf("unwired status = %d, want 501", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAdminIndexRagImportsAndLists(t *testing.T) {
|
|
t.Parallel()
|
|
rag := newRagStoreForGateway(t)
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: rag})
|
|
_ = srv
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
|
map[string]any{"name": "内部知识库.md", "desc": "团队规范", "content": "第一段:命名规范。\n\n第二段:评审流程。"}, tok)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("index status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
|
|
// The document now shows up in the list (imported + chunked).
|
|
listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
|
if listResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody)
|
|
}
|
|
var res AdminRagResponse
|
|
if err := json.Unmarshal(decodeBody(t, listBody)["data"], &res); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(res.Documents) != 1 || res.Documents[0].Name != "内部知识库.md" || res.Documents[0].Chunks != 2 {
|
|
t.Fatalf("documents = %+v, want one 内部知识库.md with 2 chunks", res.Documents)
|
|
}
|
|
|
|
// It is immediately queryable (keyword/line path).
|
|
qResp, qBody := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query",
|
|
map[string]any{"query": "评审流程"}, tok)
|
|
if qResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("query status = %d, want 200 (body=%s)", qResp.StatusCode, qBody)
|
|
}
|
|
var qres struct {
|
|
Hits []AdminRagHit `json:"hits"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, qBody)["data"], &qres); err != nil {
|
|
t.Fatalf("decode query: %v", err)
|
|
}
|
|
if len(qres.Hits) == 0 || qres.Hits[0].DocName != "内部知识库.md" {
|
|
t.Fatalf("hits = %+v, want a hit on 内部知识库.md", qres.Hits)
|
|
}
|
|
}
|
|
|
|
func TestAdminIndexRagRejectsBlankNameOrContent(t *testing.T) {
|
|
t.Parallel()
|
|
rag := newRagStoreForGateway(t)
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: rag})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
|
map[string]any{"name": " ", "content": "x"}, tok)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("blank name status = %d, want 400", resp.StatusCode)
|
|
}
|
|
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
|
map[string]any{"name": "x", "content": " "}, tok)
|
|
if resp2.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("blank content status = %d, want 400", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAdminIndexRagUnwired501(t *testing.T) {
|
|
t.Parallel()
|
|
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
|
map[string]any{"name": "x", "content": "y"}, tok)
|
|
if resp.StatusCode != http.StatusNotImplemented {
|
|
t.Fatalf("unwired status = %d, want 501", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// 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-flash", "multimodal": true, "maxTokens": 1234}, 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-flash" || res.Provider.MaxTokens != 1234 {
|
|
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" || groups[0].MaxTokens != 1234 {
|
|
t.Fatalf("file groups = %+v, want deepseek first with sk-ds and maxTokens 1234", groups)
|
|
}
|
|
|
|
// The running stack reflects the new active provider/model (hot reload).
|
|
if runtime.Provider() != "openai" || runtime.Model() != "deepseek-flash" {
|
|
t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-flash", 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-flash" || list.Providers[0].MaxTokens != 1234 {
|
|
t.Fatalf("list = %+v, want deepseek-flash first with maxTokens 1234", 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 TestAdminAddLlmProviderPartialEditPreservesFields(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)
|
|
|
|
// Seed a fully-specified group.
|
|
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-flash", "multimodal": true, "fallback": "deepseek-v4-flash", "maxTokens": 1234}, tok)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
|
|
// Partial edit: only the model is given. Every omitted field must keep its
|
|
// stored value — in particular maxTokens (a full replace reset it to 0) and
|
|
// apiKey (a full replace wiped it).
|
|
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
|
|
map[string]any{"id": "deepseek", "model": "deepseek-v4-pro"}, tok)
|
|
if resp2.StatusCode != http.StatusCreated {
|
|
t.Fatalf("edit status = %d, want 201 (body=%s)", resp2.StatusCode, body2)
|
|
}
|
|
var res struct {
|
|
Provider AdminLlmProvider `json:"provider"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body2)["data"], &res); err != nil {
|
|
t.Fatalf("decode edit: %v", err)
|
|
}
|
|
p := res.Provider
|
|
if p.Model != "deepseek-v4-pro" || p.MaxTokens != 1234 || p.Fallback != "deepseek-v4-flash" ||
|
|
!p.Multimodal || p.Provider != "openai" || p.BaseURL != "https://api.deepseek.com/v1" {
|
|
t.Fatalf("edit provider = %+v, want model updated and every omitted field preserved", p)
|
|
}
|
|
|
|
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
|
|
if warn != nil {
|
|
t.Fatalf("load models warn = %v", warn)
|
|
}
|
|
if len(groups) == 0 || groups[0].APIKey != "sk-ds" || groups[0].MaxTokens != 1234 || groups[0].Model != "deepseek-v4-pro" {
|
|
t.Fatalf("file groups = %+v, want apiKey/maxTokens preserved and model updated", groups)
|
|
}
|
|
}
|
|
|
|
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)
|
|
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: h})
|
|
_ = srv
|
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
|
|
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/sessions", nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var items []historySessionSummary
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(items) == 0 {
|
|
t.Fatal("admin sessions list is empty; main session should always exist")
|
|
}
|
|
if items[0].Type != "main" || items[0].ID == "" {
|
|
t.Fatalf("first session = %+v, want a main session with id", items[0])
|
|
}
|
|
}
|