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, ContextWindow: g.ContextWindow, 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 }