feat: 模型引用解析层 resolveModelConfig + {apiKey} 占位替换
- resolveModelConfig:引用行实时取系统行配置+本人 apiKey,Id 覆盖为系统模型 id - mergeReferenceConfig 纯函数:配置取系统行,个人字段取引用行,enabled 取 AND - substituteAPIPlaceholder 非破坏式替换(BaseURL/头/体/业务字段/异步映射) - 纯函数单测 4 个全过(service 包测试需临时 config.yml 兜底) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"model-gateway/dao"
|
||||
"model-gateway/model/dto"
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ====================== 模型引用解析层 ======================
|
||||
// 引用行(ref_system_model_id>0) 调用时实时取系统模型配置 + 本人 apiKey 合成可执行配置;
|
||||
// 系统模型调整零同步。Id 被覆盖为系统模型 id → 计价/并发键按系统模型走(会话/任务落库仍用引用行 id)。
|
||||
|
||||
const apiKeyPlaceholder = "{apiKey}"
|
||||
|
||||
// replacePlaceholder 递归替换 map/slice/string 中的占位符(泛化自 task_end 的 replaceTaskPlaceholder,非破坏式)。
|
||||
func replacePlaceholder(v any, from, to string) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return strings.ReplaceAll(val, from, to)
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = replacePlaceholder(x, from, to)
|
||||
}
|
||||
return m
|
||||
case map[string]string:
|
||||
m := make(map[string]string, len(val))
|
||||
for k, x := range val {
|
||||
m[k] = strings.ReplaceAll(x, from, to)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
arr := make([]any, len(val))
|
||||
for i, x := range val {
|
||||
arr[i] = replacePlaceholder(x, from, to)
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// copyAndReplaceStringMap 非破坏式替换 map[string]string 值(新建 map,不污染入参)
|
||||
func copyAndReplaceStringMap(src map[string]string, from, to string) map[string]string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
m[k] = strings.ReplaceAll(v, from, to)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// substituteAPIPlaceholder 把输入侧配置中的 {apiKey} 替换为生效 key(非破坏式:新建 map/struct,不污染入参)。
|
||||
// 覆盖 BaseURL / RequestHeadMapping / RequestBodyMapping / RequestBusinessFieldMapping /
|
||||
// AsyncTaskMapping(Url/RequestHeadMapping/RequestBodyMapping)。
|
||||
func substituteAPIPlaceholder(m *entity.ModelManage, key string) {
|
||||
m.BaseURL = strings.ReplaceAll(m.BaseURL, apiKeyPlaceholder, key)
|
||||
m.RequestHeadMapping = copyAndReplaceStringMap(m.RequestHeadMapping, apiKeyPlaceholder, key)
|
||||
m.RequestBusinessFieldMapping = copyAndReplaceStringMap(m.RequestBusinessFieldMapping, apiKeyPlaceholder, key)
|
||||
if v, ok := replacePlaceholder(m.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok {
|
||||
m.RequestBodyMapping = v
|
||||
}
|
||||
if a := m.AsyncTaskMapping; a != nil {
|
||||
ac := *a
|
||||
ac.Url = strings.ReplaceAll(a.Url, apiKeyPlaceholder, key)
|
||||
ac.RequestHeadMapping = copyAndReplaceStringMap(a.RequestHeadMapping, apiKeyPlaceholder, key)
|
||||
if v, ok := replacePlaceholder(a.RequestBodyMapping, apiKeyPlaceholder, key).(map[string]any); ok {
|
||||
ac.RequestBodyMapping = v
|
||||
}
|
||||
m.AsyncTaskMapping = &ac
|
||||
}
|
||||
}
|
||||
|
||||
// mergeReferenceConfig 引用行 + 系统行 → 有效配置(纯函数,便于单测)。
|
||||
// 配置字段取系统行;个人字段(apiKey/enabled/chatModel)取引用行;enabled 取 AND(系统停用=引用失效)。
|
||||
func mergeReferenceConfig(stub, sys *entity.ModelManage) *entity.ModelManage {
|
||||
out := *sys
|
||||
out.ApiKey = stub.ApiKey
|
||||
if stub.Enabled != nil {
|
||||
out.Enabled = stub.Enabled
|
||||
}
|
||||
if stub.ChatModel != nil {
|
||||
out.ChatModel = stub.ChatModel
|
||||
}
|
||||
if sys.Enabled != nil && !*sys.Enabled {
|
||||
out.Enabled = gconv.PtrBool(false)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// resolveModelConfig 把请求命中的模型行解析为可执行配置:
|
||||
// 引用行 → 系统行配置 + 引用行 apiKey(Id 覆盖为系统模型 id);非引用行 → 原配置 + 自身 apiKey 替换占位。
|
||||
// 引用系统模型已删除 → 报错(调用方阻塞)。
|
||||
func resolveModelConfig(ctx context.Context, m *entity.ModelManage) (*entity.ModelManage, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if m.RefSystemModelId > 0 {
|
||||
sys, err := dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{Id: m.RefSystemModelId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sys == nil {
|
||||
return nil, fmt.Errorf("引用的系统模型已删除")
|
||||
}
|
||||
out := mergeReferenceConfig(m, sys)
|
||||
substituteAPIPlaceholder(out, out.ApiKey)
|
||||
return out, nil
|
||||
}
|
||||
out := *m
|
||||
substituteAPIPlaceholder(&out, out.ApiKey)
|
||||
return &out, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"model-gateway/model/entity"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
func TestReplacePlaceholder(t *testing.T) {
|
||||
if got := replacePlaceholder("Bearer {apiKey}", "{apiKey}", "sk-1"); got != "Bearer sk-1" {
|
||||
t.Fatalf("string: %v", got)
|
||||
}
|
||||
got := replacePlaceholder(map[string]any{
|
||||
"url": "https://x.com?key={apiKey}",
|
||||
"n": 1,
|
||||
"sub": map[string]any{"k": "v-{apiKey}"},
|
||||
"list": []any{"a-{apiKey}", 2},
|
||||
}, "{apiKey}", "sk-9")
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("not map: %T", got)
|
||||
}
|
||||
if m["url"] != "https://x.com?key=sk-9" {
|
||||
t.Fatalf("url: %v", m["url"])
|
||||
}
|
||||
if m["n"] != 1 {
|
||||
t.Fatalf("n: %v", m["n"])
|
||||
}
|
||||
if m["sub"].(map[string]any)["k"] != "v-sk-9" {
|
||||
t.Fatalf("sub: %v", m["sub"])
|
||||
}
|
||||
if m["list"].([]any)[0] != "a-sk-9" {
|
||||
t.Fatalf("list[0]: %v", m["list"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteAPIPlaceholder(t *testing.T) {
|
||||
m := &entity.ModelManage{
|
||||
BaseURL: "https://api.xx.com?key={apiKey}",
|
||||
RequestHeadMapping: map[string]string{"Authorization": "Bearer {apiKey}"},
|
||||
RequestBodyMapping: map[string]any{"model": "{apiKey}"},
|
||||
RequestBusinessFieldMapping: map[string]string{"key": "{apiKey}"},
|
||||
AsyncTaskMapping: &entity.AsyncTaskMapping{
|
||||
Url: "https://api.xx.com/task/{taskId}?key={apiKey}",
|
||||
RequestBodyMapping: map[string]any{"auth": "{apiKey}"},
|
||||
},
|
||||
}
|
||||
substituteAPIPlaceholder(m, "sk-user")
|
||||
if m.BaseURL != "https://api.xx.com?key=sk-user" {
|
||||
t.Fatalf("baseURL: %s", m.BaseURL)
|
||||
}
|
||||
if m.RequestHeadMapping["Authorization"] != "Bearer sk-user" {
|
||||
t.Fatalf("head: %s", m.RequestHeadMapping["Authorization"])
|
||||
}
|
||||
if m.RequestBodyMapping["model"] != "sk-user" {
|
||||
t.Fatalf("body: %v", m.RequestBodyMapping["model"])
|
||||
}
|
||||
if m.RequestBusinessFieldMapping["key"] != "sk-user" {
|
||||
t.Fatalf("biz: %v", m.RequestBusinessFieldMapping["key"])
|
||||
}
|
||||
if m.AsyncTaskMapping.Url != "https://api.xx.com/task/{taskId}?key=sk-user" {
|
||||
t.Fatalf("async url: %s", m.AsyncTaskMapping.Url)
|
||||
}
|
||||
if m.AsyncTaskMapping.RequestBodyMapping["auth"] != "sk-user" {
|
||||
t.Fatalf("async body: %v", m.AsyncTaskMapping.RequestBodyMapping["auth"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeReferenceConfig(t *testing.T) {
|
||||
sys := &entity.ModelManage{
|
||||
SQLBaseDO: beans.SQLBaseDO{Id: 100}, BaseURL: "https://sys", SystemModel: gconv.PtrBool(true),
|
||||
Enabled: gconv.PtrBool(true), RequestHeadMapping: map[string]string{"Authorization": "Bearer {apiKey}"},
|
||||
}
|
||||
stub := &entity.ModelManage{RefSystemModelId: 100, ApiKey: "sk-user", Enabled: gconv.PtrBool(true), ChatModel: gconv.PtrBool(true)}
|
||||
|
||||
out := mergeReferenceConfig(stub, sys)
|
||||
if out.Id != 100 {
|
||||
t.Fatalf("id: %d", out.Id)
|
||||
}
|
||||
if out.ApiKey != "sk-user" {
|
||||
t.Fatalf("apiKey: %s", out.ApiKey)
|
||||
}
|
||||
if out.ChatModel == nil || !*out.ChatModel {
|
||||
t.Fatalf("chatModel should follow stub")
|
||||
}
|
||||
if out.BaseURL != "https://sys" {
|
||||
t.Fatalf("baseURL should follow sys: %s", out.BaseURL)
|
||||
}
|
||||
|
||||
// 系统停用 → 引用失效
|
||||
sysOff := &entity.ModelManage{Enabled: gconv.PtrBool(false)}
|
||||
if out := mergeReferenceConfig(stub, sysOff); out.Enabled == nil || *out.Enabled {
|
||||
t.Fatalf("system disabled should gate reference")
|
||||
}
|
||||
// 引用行停用 → 停用
|
||||
stubOff := &entity.ModelManage{Enabled: gconv.PtrBool(false)}
|
||||
if out := mergeReferenceConfig(stubOff, sys); out.Enabled == nil || *out.Enabled {
|
||||
t.Fatalf("stub disabled should gate")
|
||||
}
|
||||
// 引用行未设 enabled → 用系统行
|
||||
stubNil := &entity.ModelManage{ApiKey: "sk-user"}
|
||||
if out := mergeReferenceConfig(stubNil, sys); out.Enabled == nil || !*out.Enabled {
|
||||
t.Fatalf("stub nil enabled should follow sys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelConfigNonReference(t *testing.T) {
|
||||
m := &entity.ModelManage{SQLBaseDO: beans.SQLBaseDO{Id: 5}, BaseURL: "https://x?key={apiKey}", ApiKey: "sk-sys"}
|
||||
out, err := resolveModelConfig(nil, m) // 非引用路径不触 DB,ctx 可为 nil
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if out.BaseURL != "https://x?key=sk-sys" {
|
||||
t.Fatalf("baseURL: %s", out.BaseURL)
|
||||
}
|
||||
if out.Id != 5 {
|
||||
t.Fatalf("id: %d", out.Id)
|
||||
}
|
||||
if out == m {
|
||||
t.Fatalf("should return a copy, not mutate input")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user