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」切换
This commit is contained in:
1 parent
8890c30bb5
commit
1ac02bd77e
12 files changed
+998
-85
No files matched your search
@@ -38,14 +38,28 @@ type policyRequest struct {
|
||||
AllowInStranger bool `json:"allowInStranger"`
|
||||
}
|
||||
|
||||
// AdminLlmReasoning 是一个 provider 组的推理开关形状声明(models.json 的
|
||||
// `reasoning` 块的线上投影)。kind 为空串表示「未声明形状」(等同 auto:
|
||||
// 按模型名/baseURL 推断,有歧义就不下发),而不是「后端没报告」——
|
||||
// 后者由整个字段缺失表示。
|
||||
type AdminLlmReasoning struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// AdminLlmProvider 是管理配置中一个 LLM provider 的线上结构。
|
||||
// API key 刻意缺位:它绝不返回给客户端。
|
||||
//
|
||||
// 这里列出的每个可编辑字段都必须在读侧如实回填:面板是「读回来 → 改一处 →
|
||||
// 整组提交」,读侧少一个键就等于保存时把它抹掉。
|
||||
type AdminLlmProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Model string `json:"model"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Model string `json:"model"`
|
||||
// Multimodal = **主模型自己能否收图**(裁决 14.4.1)。它不表示「本部署
|
||||
// 有视觉能力」:图片识别/OCR 工具用的模型由 .env 的 LLM_VISION_MODEL /
|
||||
// LLM_OCR_MODEL 单独配置,与这里无关。
|
||||
Multimodal bool `json:"multimodal"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
@@ -53,11 +67,28 @@ type AdminLlmProvider struct {
|
||||
// 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS,
|
||||
// 再回退到适配器默认值)。
|
||||
MaxTokens int `json:"maxTokens,omitempty"`
|
||||
// Reasoning 是该组的推理开关形状(裁决 03.4.2 / 08.4.1)。始终下发
|
||||
// (kind 可能为空串),使面板能把「未声明」当成一种明确状态回填。
|
||||
Reasoning *AdminLlmReasoning `json:"reasoning"`
|
||||
// PersonaModels 是 persona → 模型名的路由表(裁决 03.4.1),键为该组内
|
||||
// 的 persona id;空/缺失 = 该组没有 per-persona 模型绑定。
|
||||
PersonaModels map[string]string `json:"personaModels,omitempty"`
|
||||
// PersonaProviders 是 persona → wire 协议的覆盖表,取值只能是本 build
|
||||
// 承认的协议名(见 AdminLlmConfig.PersonaProviderOptions)。
|
||||
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLlmConfig 是 GET /api/v1/admin/llm 的线上结构。
|
||||
//
|
||||
// ReasoningKinds / PersonaProviderOptions 是**面板表单选项的取值域**:它们由
|
||||
// 配置层的常量清单直接生成,因此面板给出的候选与后端会接受的取值永远一致
|
||||
// (面板不硬编码自己的一套枚举)。
|
||||
type AdminLlmConfig struct {
|
||||
Providers []AdminLlmProvider `json:"providers"`
|
||||
// ReasoningKinds 是 responses 中 reasoning.kind 的合法取值(不含空串)。
|
||||
ReasoningKinds []string `json:"reasoningKinds"`
|
||||
// PersonaProviderOptions 是 personaProviders 值的合法取值(协议名)。
|
||||
PersonaProviderOptions []string `json:"personaProviderOptions"`
|
||||
}
|
||||
|
||||
// AdminAutomationRecord 是管理列表中一条自动化/提醒的线上结构。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// LLM provider 分组的线上投影:读(GET /admin/llm)与写(POST /admin/llm 的
|
||||
// 响应)**必须同一套映射**。面板是「读回来 → 改一处 → 整组提交」,两个方向
|
||||
// 一旦不一致(例如读侧漏了 reasoning),面板保存一次就会把那把键抹掉。
|
||||
|
||||
// adminLlmProviderDTO 把存储分组映射成线上的 AdminLlmProvider。API key 绝不
|
||||
// 进入线上结构。reasoning 无条件下发(kind 可能为空串 = 未声明形状),
|
||||
// persona 路由表按原值深拷贝,使调用方拿到的结构不会被后续改动影响。
|
||||
func adminLlmProviderDTO(g runtimeconfig.Group, name string, enabled bool) AdminLlmProvider {
|
||||
return AdminLlmProvider{
|
||||
ID: g.ID,
|
||||
Name: name,
|
||||
Provider: g.Provider,
|
||||
BaseURL: g.BaseURL,
|
||||
Model: g.Model,
|
||||
Multimodal: g.Multimodal,
|
||||
Enabled: enabled,
|
||||
Fallback: g.Fallback,
|
||||
MaxTokens: g.MaxTokens,
|
||||
Reasoning: &AdminLlmReasoning{Kind: strings.TrimSpace(g.Reasoning.Kind)},
|
||||
PersonaModels: cloneStringMap(g.PersonaModels),
|
||||
PersonaProviders: cloneStringMap(g.PersonaProviders),
|
||||
}
|
||||
}
|
||||
|
||||
// cloneStringMap 浅拷贝一张 persona 路由表。nil 保持 nil,因此 omitempty 仍会
|
||||
// 省略该键——「该组没有 per-persona 绑定」与「绑定表为空对象」在线上是同一种
|
||||
// 状态(都表示没有路由),不需要两套表示。
|
||||
func cloneStringMap(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePersonaMap 规整面板提交的 persona 路由表:键与值去除首尾空白。
|
||||
// 只做规整,绝不丢弃或改写成别的值——空键/空值/未知协议名一律留给
|
||||
// runtimeconfig.Group.Validate 报错(写端点据此回 400),因此非法输入
|
||||
// 永远不会被静默吸收。规整的理由是路由查找用的是 trim 后的 persona id,
|
||||
// 带空白的键会变成永远匹配不上的静默死配置;两个键在 trim 后撞到一起
|
||||
// (" cyrene " 与 "cyrene")则直接报错,而不是悄悄让其中一个消失。
|
||||
func normalizePersonaMap(field string, m map[string]string) (map[string]string, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
key := strings.TrimSpace(k)
|
||||
if _, dup := out[key]; dup {
|
||||
return nil, fmt.Errorf("%s: persona id %q 去除首尾空白后与另一条目重复", field, key)
|
||||
}
|
||||
out[key] = strings.TrimSpace(v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -274,24 +274,20 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
|
||||
// adminGetLlm 返回从实时 provider 栈读取的 LLM provider/model
|
||||
// 配置(models.json,回退到 .env)。API key 绝不返回 ——
|
||||
// 线上结构中不含 key 字段 —— 因此该 handler
|
||||
// 不泄露任何敏感信息。
|
||||
// 不泄露任何敏感信息。除分组本身外还下发**表单选项的取值域**
|
||||
// (reasoningKinds / personaProviderOptions),使面板的下拉框与后端校验
|
||||
// 永远同一份来源。
|
||||
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
|
||||
groups := s.currentLlmGroups()
|
||||
providers := make([]AdminLlmProvider, 0, len(groups))
|
||||
for i, g := range groups {
|
||||
providers = append(providers, AdminLlmProvider{
|
||||
ID: g.ID,
|
||||
Name: providerDisplayName(g),
|
||||
Provider: g.Provider,
|
||||
BaseURL: g.BaseURL,
|
||||
Model: g.Model,
|
||||
Multimodal: g.Multimodal,
|
||||
Enabled: i == 0,
|
||||
Fallback: g.Fallback,
|
||||
MaxTokens: g.MaxTokens,
|
||||
})
|
||||
providers = append(providers, adminLlmProviderDTO(g, providerDisplayName(g), i == 0))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
|
||||
writeJSON(w, http.StatusOK, AdminLlmConfig{
|
||||
Providers: providers,
|
||||
ReasoningKinds: runtimeconfig.ReasoningKinds(),
|
||||
PersonaProviderOptions: runtimeconfig.ProviderNames(),
|
||||
})
|
||||
}
|
||||
|
||||
// currentLlmGroups 返回要展示的 provider 分组:接线了实时栈时
|
||||
|
||||
@@ -36,12 +36,19 @@ const (
|
||||
llmProbePrompt = "Reply with the single word: OK"
|
||||
)
|
||||
|
||||
// llmReasoningRequest 是请求体里的推理开关形状块。整块缺省 = 保留已存形状;
|
||||
// 整块给出(哪怕只有 `{}`)= 整块替换,因此面板能把形状改回「未声明」。
|
||||
type llmReasoningRequest struct {
|
||||
Kind *string `json:"kind"`
|
||||
}
|
||||
|
||||
// llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新
|
||||
// provider 分组。每个字段都是指针,使 handler 能区分「缺失」
|
||||
// (保留已存值)与「显式零值」(覆盖):来自面板的局部
|
||||
// provider 分组。每个字段都是指针(映射类型用 nil 判定),使 handler 能区分
|
||||
// 「缺失」(保留已存值)与「显式零值」(覆盖):来自面板的局部
|
||||
// 编辑绝不清除它未触碰的字段。Name 是客户端侧
|
||||
// 显示标签(不持久化 —— models.json 结构含 id/provider/
|
||||
// baseUrl/apiKey/model/fallback/multimodal/maxTokens)。
|
||||
// baseUrl/apiKey/model/fallback/multimodal/maxTokens/reasoning/personaModels/
|
||||
// personaProviders)。
|
||||
type llmProviderRequest struct {
|
||||
ID *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
@@ -54,6 +61,14 @@ type llmProviderRequest struct {
|
||||
// MaxTokens 是该分组自身的 token 预算。缺失则保留已存
|
||||
// 预算(新分组 0 = 未设置)。负值被拒绝。
|
||||
MaxTokens *int `json:"maxTokens"`
|
||||
// Reasoning 缺失 = 保留已存形状;给出(含 `{}`)= 用其中的 kind 覆盖
|
||||
// (kind 缺失/空串 = 回到「未声明」,等同 auto)。
|
||||
Reasoning *llmReasoningRequest `json:"reasoning"`
|
||||
// PersonaModels / PersonaProviders 是 persona 路由表,缺失 = 保留已存表,
|
||||
// 给出(含 `{}`)= 整表替换。用整表替换而非合并:否则面板无法删除一条
|
||||
// 已存路由,只能覆盖它的值。
|
||||
PersonaModels map[string]string `json:"personaModels"`
|
||||
PersonaProviders map[string]string `json:"personaProviders"`
|
||||
}
|
||||
|
||||
// adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write,
|
||||
@@ -98,17 +113,7 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload"))
|
||||
return
|
||||
}
|
||||
saved := AdminLlmProvider{
|
||||
ID: group.ID,
|
||||
Name: providerDisplayName(group),
|
||||
Provider: group.Provider,
|
||||
BaseURL: group.BaseURL,
|
||||
Model: group.Model,
|
||||
Multimodal: group.Multimodal,
|
||||
Enabled: group.ID == s.llm.Group().ID,
|
||||
Fallback: group.Fallback,
|
||||
MaxTokens: group.MaxTokens,
|
||||
}
|
||||
saved := adminLlmProviderDTO(group, providerDisplayName(group), group.ID == s.llm.Group().ID)
|
||||
s.auditClaims(r, "llm.addProvider", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"provider": saved,
|
||||
@@ -119,7 +124,10 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// mergeLlmProvider 把局部 provider 补丁施加到已存分组之上:
|
||||
// 请求中出现的字段胜出,缺失字段保留已存值(新分组
|
||||
// 则用其默认值)。它返回经过校验的分组。
|
||||
// 则用其默认值)。它返回经过校验的分组——校验走配置层同一入口
|
||||
// (runtimeconfig.Group.Validate),因此非法的 reasoning.kind /
|
||||
// 非法的 personaProviders 协议名都在这里以错误浮出(写端点回 400),
|
||||
// 绝不静默丢弃或悄悄改成别的值。
|
||||
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
|
||||
id := ""
|
||||
if req.ID != nil {
|
||||
@@ -160,12 +168,42 @@ func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (run
|
||||
if req.MaxTokens != nil {
|
||||
g.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.Reasoning != nil {
|
||||
g.Reasoning = runtimeconfig.ReasoningOptions{Kind: reasoningKindOf(req.Reasoning)}
|
||||
}
|
||||
if req.PersonaModels != nil {
|
||||
models, err := normalizePersonaMap("personaModels", req.PersonaModels)
|
||||
if err != nil {
|
||||
return runtimeconfig.Group{}, err
|
||||
}
|
||||
g.PersonaModels = models
|
||||
}
|
||||
if req.PersonaProviders != nil {
|
||||
providers, err := normalizePersonaMap("personaProviders", req.PersonaProviders)
|
||||
if err != nil {
|
||||
return runtimeconfig.Group{}, err
|
||||
}
|
||||
g.PersonaProviders = providers
|
||||
}
|
||||
if g.BaseURL == "" || g.Model == "" {
|
||||
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
|
||||
}
|
||||
if err := g.Validate(); err != nil {
|
||||
return runtimeconfig.Group{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// reasoningKindOf 取出请求里的形状名:kind 缺失/空串 = 未声明形状(等同 auto,
|
||||
// 等同不写这把键),因此面板能显式「改回未声明」而不是被局部编辑语义锁死在
|
||||
// 已存形状上。
|
||||
func reasoningKindOf(req *llmReasoningRequest) string {
|
||||
if req == nil || req.Kind == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*req.Kind)
|
||||
}
|
||||
|
||||
// llmTestRequest 是 POST /api/v1/admin/llm/test 的请求体。
|
||||
type llmTestRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
|
||||
Reference in new issue
Block a user