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
@@ -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